title: 深刻探索Android卡頓優化(上)
date: 2020/1/26 20:12:00
tags:
- 性能優化
categories: 性能優化
thumbnail: https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1557665970516&di=b58d306a0db07efca58f8c9b655f5c13&imgtype=0&src=http%3A%2F%2Fimg02.tooopen.com%2Fimages%2F20160520%2Ftooopen_sl_055418231108.jpg
複製代碼
在上篇,筆者詳細分析了目前的App繪製與佈局優化的相關優化方案,若是對繪製優化與佈局優化還不是很是熟悉的能夠仔細看看前幾篇文章:Android性能優化之繪製優化、深刻探索Android佈局優化(上)、深刻探索Android佈局優化(下)。因爲卡頓優化這一主題包含的內容太多,爲了更詳細地進行講解,所以,筆者將它分爲了上、下兩篇。本篇,即爲《深刻探索Android卡頓優化》的上篇。本篇包含的主要內容以下所示:html
在咱們使用各類各樣的App的時候,有時會看見有些App運行起來並不流暢,即出現了卡頓現象,那麼如何去定義發生了卡頓現象呢?java
若是App的FPS平均值小於30,最小值小於24,即代表應用發生了卡頓。
複製代碼
那我麼又如何去分析應用是否出現了卡頓呢?下面,咱們就先來了解一下解決卡頓問題時須要用到的分析方法與工具。python
儘管形成卡頓的緣由有不少種,不過最終都會反映到CPU時間上。linux
CPU時間包含用戶時間和系統時間。android
CPU的問題大體能夠分爲如下三類:git
一、CPU資源冗餘使用github
二、CPU資源爭搶web
三、CPU資源利用率低算法
對於啓動、界面切換、音視頻編解碼這些場景,爲了保證其速度,咱們須要去好好利用CPU。而致使沒法充分利用CPU的因素,不只有磁盤和網絡I/O,還有鎖操做、sleep等等。對於鎖的優化,一般是儘量地縮減鎖的範圍。shell
咱們能夠經過CPU的主頻、核心數、緩存等參數去評估CPU的性能,這些參數的好壞能表現出CPU計算能力和指令執行能力的強弱,也就是CPU每秒執行的浮點計算數和每秒執行的指令數的多少。
此外,如今最新的主流機型都使用了多級能效的CPU架構(即多核分層架構),以確保在日常低負荷工做時能僅使用低頻核心來節省電量。
而且,咱們還能夠經過shell命令直接查看手機的CPU核心數與頻率等信息,以下所示:
// 先輸入adb shell進入手機的shell環境
adb shell
// 獲取 CPU 核心數,個人手機是8核
platina:/ $ cat /sys/devices/system/cpu/possible
0-7
// 獲取第一個 CPU 的最大頻率
platina:/ $ cat
/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq <
1843200
// 獲取第二個CPU的最小頻率
platina:/ $ cat
/sys/devices/system/cpu/cpu1/cpufreq/cpuinfo_min_freq <
633600
複製代碼
從 CPU 到 GPU 再到 AI 芯片(如專爲神經網絡計算打造的 NPU(Neural network Processing Unit)),隨着手機 CPU 總體性能的飛躍,醫療診斷、圖像超清化等一些 AI 應用場景也能夠在移動端更好地落地。咱們能夠充分利用移動端的計算能力來下降高昂的服務器成本。
此外,CPU的性能越好,應用就能得到更好的支持,如線程池能夠根據不一樣手機的CPU核心數來配備不一樣的線程數、僅在手機主頻比較高或者帶有NPU的設備去開啓一些高級的AI功能。
當應用出現卡頓問題以後,首先咱們應該查看系統CPU的使用率。
首先,咱們經過讀取 /proc/stat 文件獲取總的 CPU 時間,並讀取 /proc/[PID]/stat 獲取應用進程 的CPU 時間,而後,採樣兩個足夠短的時間間隔的 CPU 快照與進程快照來計算其 CPU 使用率。
一、採樣兩個足夠短的時間間隔的 CPU 快照,即須要先後兩次去讀取 /proc/stat 文件,獲取兩個時間點對應的數據,以下所示:
// 第一次採樣
platina:/ $ cat /proc/stat
cpu 9931551 1082101 9002534 174463041 340947 1060438 1088978 0 0 0
cpu0 2244962 280573 2667000 22414199 99651 231869 439918 0 0 0
cpu1 2672378 421880 2943791 21540302 121818 236850 438733 0 0 0
cpu2 1648512 76856 1431036 25868789 46970 107094 52025 0 0 0
cpu3 1418757 41280 1397203 25772984 40292 110168 41667 0 0 0
cpu4 573203 79498 178263 19618235 9577 307949 10875 0 0 0
cpu5 522638 67978 155454 19684358 8793 19787 4603 0 0 0
cpu6 458438 64085 132252 19749439 8143 19942 98241 0 0 0
cpu7 392663 49951 97535 19814735 5703 26779 2916 0 0 0
intr...
// 第二次採樣
platina:/ $ cat /proc/stat
cpu 9931673 1082113 9002679 174466561 340954 1060446 1088994 0 0 0
cpu0 2244999 280578 2667032 22414604 99653 231869 439918 0 0 0
cpu1 2672434 421881 2943861 21540606 121822 236855 438747 0 0 0
cpu2 1648525 76859 1431054 25869234 46971 107095 52026 0 0 0
cpu3 1418773 41283 1397228 25773412 40292 110170 41668 0 0 0
cpu4 573203 79498 178263 19618720 9577 307949 10875 0 0 0
cpu5 522638 67978 155454 19684842 8793 19787 4603 0 0 0
cpu6 458438 64085 132252 19749923 8143 19942 98241 0 0 0
cpu7 392663 49951 97535 19815220 5703 26779 2916 0 0 0
int...
複製代碼
由於個人手機是8核,因此這裏的cpu個數是8個,從cpu0到cpu7,第一行的cpu便是8個cpu的指標數據彙總,由於是要計算系統cpu的使用率,那固然應該以cpu爲基準了。兩次採樣的CPU指標數據以下:
cpu1 9931551 1082101 9002534 174463041 340947 1060438 1088978 0 0 0
cpu2 9931673 1082113 9002679 174466561 340954 1060446 1088994 0 0 0
複製代碼
其對應的各項指標以下:
CPU (user, nice, system, idle, iowait, irq, softirq, stealstolen, guest);
複製代碼
拿cpu1(9931551 1082101 9002534 174463041 340947 1060438 1088978 0 0 0)的數據來講,下面,我就來詳細地解釋下這些指標的含義。
此外,這些數值的單位都是 jiffies,jiffies 是內核中的一個全局變量,用來記錄系統啓動以來產生的節拍數,在 Linux 中,一個節拍大體能夠理解爲操做系統進程調度的最小時間片,不一樣的 Linux 系統內核中的這個值可能不一樣,一般在 1ms 到 10ms 之間。
瞭解了/proc/stat命令下各項參數的含義以後,咱們就能夠由先後兩次時間點的CPU數據計算獲得cpu1與cpu2的活動時間,以下所示:
totalCPUTime = user + nice + system + idle + iowait + irq + softirq + stealstolen + guest
cpu1 = 9931551 + 1082101 + 9002534 + 174463041 + 340947 + 1060438 + 1088978 + 0 + 0 + 0 = 196969590jiffies
cpu2 = 9931673 + 1082113 + 9002679 + 174466561 + 340954 + 1060446 + 1088994 + 0 + 0 + 0 = 196973420jiffies
複製代碼
所以可得出總的CPU時間,以下所示:
totalCPUTime = CPU2 – CPU1 = 3830jiffies
複製代碼
最後,咱們就能夠計算出系統CPU的使用率::
// 先計算獲得CPU的空閒時間
idleCPUTime = idle2 – idle1 = 3520jiffies
// 最後獲得系統CPU的使用率
totalCPUUse = (totalCPUTime – idleCPUTime) / totalCPUTime = (3830 - 3520)/ 3830 = 8%
複製代碼
能夠看到,先後兩次時間點間的CPU使用率大概爲8%,說明咱們系統的CPU是處於空閒狀態的,若是CPU 使用率一直大於 60% ,則表示系統處於繁忙狀態,此時就須要進一步分析用戶時間和系統時間的比例,看看究竟是系統佔用了CPU仍是應用進程佔用了CPU。
此外,因爲Android是基於Linux內核改造而成的操做系統,天然而然也能使用Linux的一些經常使用命令。好比咱們可使用top命令查看哪些進程是 CPU 的主要消耗者。
// 直接使用top命令會定時不斷地輸出進程的相關信息
1|platina:/ $ top
PID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ ARGS
12700 u0_a945 10 -10 4.3G 122M 67M S 15.6 2.1 1:06.41 json.chao.com.w+
753 system RT 0 90M 1.1M 1.0M S 13.6 0.0 127:47.73 android.hardwar+
2064 system 18 -2 4.6G 309M 215M S 12.3 5.4 978:15.18 system_server
22142 u0_a163 20 0 2.0G 97M 41M S 10.3 1.6 2:22.99 com.tencent.mob+
2293 system 20 0 4.7G 250M 87M S 8.6 4.3 353:15.77 com.android.sys+
複製代碼
從以上可知咱們的Awesome-WanAndroid應用進程佔用了15.6%的CPU。最後,這裏再列舉下最經常使用的top命令,以下所示:
// 排除0%的進程信息
adb shell top | grep -v '0% S'
// 只打印1次按CPU排序的TOP 10的進程信息
adb shell top -m 10 -s cpu -n 1
|platina:/ $ top -d 1|grep json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M S 13.8 2.2 1:04.46 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M S 19.0 2.2 1:04.51 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M S 15.0 2.2 1:04.70 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M S 9.0 2.2 1:04.85 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M S 26.0 2.2 1:04.94 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M S 9.0 2.2 1:05.20 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M R 17.0 2.2 1:05.29 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M S 20.0 2.2 1:05.46 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M S 9.0 2.2 1:05.66 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M R 21.0 2.2 1:05.75 json.chao.com.w+
5689 u0_a945 10 -10 4.3G 129M 71M S 14.0 2.2 1:05.96 json.chao.com.w+
複製代碼
除了top命令能夠比較全面地查看總體的CPU信息以外,若是咱們只想查看當前指定進程已經消耗的CPU時間佔系統總時間的百分比或其它的狀態信息的話,可使用ps命令,經常使用的ps命令以下所示:
// 查看指定進程的狀態信息
platina:/ $ ps -p 31333
USER PID PPID VSZ RSS WCHAN ADDR S NAME
u0_a945 31333 1277 4521308 127460 0 0 S json.chao.com.w+
// 查看指定進程已經消耗的CPU時間佔系統總時間的百分比
platina:/ $ ps -o PCPU -p 31333
%CPU
10.8
複製代碼
其中輸出參數的含義以下所示:
最後的輸出參數S表示的是進程當前的狀態,總共有10種可能的狀態,以下所示:
R (running) S (sleeping) D (device I/O) T (stopped) t (traced)
Z (zombie) X (deader) x (dead) K (wakekill) W (waking)
複製代碼
能夠看到,咱們當前主進程是休眠的狀態。
使用dumpsys cpuinfo命令得到的信息比起top命令獲得的信息要更加精煉,以下所示:
platina:/ $ dumpsys cpuinfo
Load: 1.92 / 1.59 / 0.97
CPU usage from 45482ms to 25373ms ago (2020-02-04 17:00:37.666 to 2020-02-04 17:00:57.775):
33% 2060/system_server: 22% user + 10% kernel / faults: 8152 minor 6 major
17% 2292/com.android.systemui: 12% user + 4.7% kernel / faults: 21636 minor 3 major
14% 750/android.hardware.sensors@1.0-service: 4.4% user + 10% kernel
6.1% 778/surfaceflinger: 3.3% user + 2.7% kernel / faults: 128 minor
3.3% 2598/com.miui.home: 2.8% user + 0.4% kernel / faults: 7655 minor 11 major
2.2% 2914/cnss_diag: 1.6% user + 0.6% kernel
1.9% 745/android.hardware.graphics.composer@2.1-service: 1.4% user + 0.5% kernel / faults: 5 minor
1.7% 4525/kworker/u16:6: 0% user + 1.7% kernel
1.6% 748/android.hardware.memtrack@1.0-service: 0.6% user + 0.9% kernel
1.4% 4551/kworker/u16:14: 0% user + 1.4% kernel
1.4% 31333/json.chao.com.wanandroid: 0.9% user + 0.4% kernel / faults: 3995 minor 22 major
1.1% 6670/kworker/u16:0: 0% user + 1.1% kernel
0.9% 448/mmc-cmdqd/0: 0% user + 0.9% kernel
0.7% 95/system: 0% user + 0.7% kernel
0.6% 4512/mdss_fb0: 0% user + 0.6% kernel
0.6% 7393/com.android.incallui: 0.6% user + 0% kernel / faults: 2272 minor
0.6% 594/logd: 0.4% user + 0.1% kernel / faults: 38 minor 3 major
0.5% 3108/com.xiaomi.xmsf: 0.2% user + 0.2% kernel / faults: 1812 minor
0.5% 4526/kworker/u16:9: 0% user + 0.5% kernel
0.5% 4621/com.gotokeep.keep: 0.3% user + 0.1% kernel / faults: 55 minor
0.5% 354/irq/267-NVT-ts: 0% user + 0.5% kernel
0.5% 2572/com.android.phone: 0.3% user + 0.1% kernel / faults: 323 minor
0.5% 4554/kworker/u16:15: 0% user + 0.5% kernel
0.4% 290/kgsl_worker_thr: 0% user + 0.4% kernel
0.3% 2933/irq/61-1008000.: 0% user + 0.3% kernel
0.3% 3932/com.tencent.mm: 0.2% user + 0% kernel / faults: 647 minor 1 major
0.3% 4550/kworker/u16:13: 0% user + 0.3% kernel
0.3% 744/android.hardware.graphics.allocator@2.0-service: 0% user + 0.3% kernel / faults: 48 minor
0.3% 8906/com.tencent.mm:appbrand0: 0.2% user + 0% kernel / faults: 45 minor
0.2% 79/smem_native_rpm: 0% user + 0.2% kernel
0.2% 759/vendor.qti.hardware.perf@1.0-service: 0% user + 0.2% kernel / faults: 46 minor
0.2% 3197/com.miui.powerkeeper: 0% user + 0.1% kernel / faults: 141 minor
0.2% 4489/kworker/1:1: 0% user + 0.2% kernel
0.2% 595/servicemanager: 0% user + 0.2% kernel
0.2% 754/android.hardware.wifi@1.0-service: 0.1% user + 0% kernel
0.2% 1258/jbd2/dm-2-8: 0% user + 0.2% kernel
0.2% 5800/com.eg.android.AlipayGphone: 0.1% user + 0% kernel / faults: 48 minor
0.2% 21590/iptables-restore: 0% user + 0.1% kernel / faults: 563 minor
0.2% 21592/ip6tables-restore: 0% user + 0.1% kernel / faults: 647 minor
0.1% 3/ksoftirqd/0: 0% user + 0.1% kernel
0.1% 442/cfinteractive: 0% user + 0.1% kernel
0.1% 568/ueventd: 0% user + 0% kernel
0.1% 1295/netd: 0% user + 0.1% kernel / faults: 250 minor
0.1% 3002/com.miui.securitycenter.remote: 0.1% user + 0% kernel / faults: 818 minor 1 major
0.1% 20555/com.eg.android.AlipayGphone:push: 0% user + 0% kernel / faults: 20 minor
0.1% 7/rcu_preempt: 0% user + 0.1% kernel
0.1% 15/ksoftirqd/1: 0% user + 0.1% kernel
0.1% 76/lpass_smem_glin: 0% user + 0.1% kernel
0.1% 1299/rild: 0.1% user + 0% kernel / faults: 12 minor
0.1% 1448/android.process.acore: 0.1% user + 0% kernel / faults: 1719 minor
0% 4419/com.google.android.webview:s: 0% user + 0% kernel / faults: 602 minor
0% 20465/com.miui.hybrid: 0% user + 0% kernel / faults: 1575 minor
0% 10/rcuop/0: 0% user + 0% kernel
0% 75/smem_native_lpa: 0% user + 0% kernel
0% 90/kcompactd0: 0% user + 0% kernel
0% 1508/msm_irqbalance: 0% user + 0% kernel
0% 1745/cds_mc_thread: 0% user + 0% kernel
0% 2899/charge_logger: 0% user + 0% kernel
0% 3612/com.tencent.mm:tools: 0% user + 0% kernel / faults: 29 minor
0% 4203/kworker/0:0: 0% user + 0% kernel
0% 7377/com.android.server.telecom:ui: 0% user + 0% kernel / faults: 1083 minor
0% 32113/com.tencent.mobileqq: 0% user + 0% kernel / faults: 49 minor
0% 8/rcu_sched: 0% user + 0% kernel
0% 22/ksoftirqd/2: 0% user + 0% kernel
0% 25/rcuop/2: 0% user + 0% kernel
0% 29/ksoftirqd/3: 0% user + 0% kernel
0% 39/rcuop/4: 0% user + 0% kernel
0% 53/rcuop/6: 0% user + 0% kernel
0% 487/irq/715-ima-rdy: 0% user + 0% kernel
0% 749/android.hardware.power@1.0-service: 0% user + 0% kernel
0% 764/healthd: 0% user + 0% kernel / faults: 2 minor
0% 845/wlan_logging_th: 0% user + 0% kernel
0% 860/mm-pp-dpps: 0% user + 0% kernel
0% 1297/wificond: 0% user + 0% kernel / faults: 12 minor
0% 1309/com.miui.weather2: 0% user + 0% kernel / faults: 729 minor 23 major
0% 1542/rild: 0% user + 0% kernel / faults: 3 minor
0% 2915/tcpdump: 0% user + 0% kernel / faults: 6 minor
0% 2974/com.tencent.mobileqq:MSF: 0% user + 0% kernel / faults: 121 minor
0% 3044/com.miui.contentcatcher: 0% user + 0% kernel / faults: 315 minor
0% 3057/com.miui.dmregservice: 0% user + 0% kernel / faults: 332 minor
0% 3095/com.xiaomi.mircs: 0% user + 0% kernel
0% 3115/com.xiaomi.finddevice: 0% user + 0% kernel / faults: 270 minor 3 major
0% 3513/com.xiaomi.metoknlp: 0% user + 0% kernel / faults: 136 minor
0% 3603/com.tencent.mm:toolsmp: 0% user + 0% kernel / faults: 35 minor
0% 4527/kworker/u16:11: 0% user + 0% kernel
0% 4841/com.gotokeep.keep:xg_service_v4: 0% user + 0% kernel / faults: 275 minor
0% 5064/com.sohu.inputmethod.sogou.xiaomi: 0% user + 0% kernel / faults: 102 minor
0% 5257/kworker/0:1: 0% user + 0% kernel
0% 5839/com.tencent.mm:push: 0% user + 0% kernel / faults: 98 minor
0% 6644/kworker/3:2: 0% user + 0% kernel
0% 6657/com.miui.wmsvc: 0% user + 0% kernel / faults: 52 minor
0% 6945/com.xiaomi.account:accountservice: 0% user + 0% kernel / faults: 1 minor
0% 9387/com.tencent.mm:appbrand1: 0% user + 0% kernel / faults: 27 minor
13% TOTAL: 6.8% user + 5.3% kernel + 0.2% iowait + 0.3% irq + 0.4% softirq
複製代碼
從上述信息可知,第一行顯示的是cpuload (負載平均值)信息:Load: 1.92 / 1.59 / 0.97 這三個數字表示逐漸變長的時間段(平均一分鐘,五分鐘和十五分鐘)的平均值,而較低的數字則更好。數字越大表示有問題或機器過載。須要注意的是,這裏的Load須要除以核心數,好比我這裏的系統核心數爲8核,因此最終每個單核CPU的Load爲0.24 / 0.20 / 0.12,若是Load超過1,則表示出現了問題。
此外,佔用系統CPU資源最高的是system_server進程,而咱們的wanandroid應用進程僅佔用了 1.4%的CPU資源,其中有0.9%的是用戶態所佔用的時間,0.4%是內核態所佔用的時間。最後,咱們能夠看到系統總佔用的CPU時間是13%,這個值是根據前面全部值加起來 / 系統CPU數的處理的,也就是104% / 8 = 13%。
除了上述方式來分析系統與應用的CPU使用狀況以外,咱們還應該關注卡頓率與卡頓樹這兩個指標。它們能幫助咱們有效地去評估、而且更有針對性地去優化應用發生的卡頓。
相似於深刻探索Android穩定性優化一文中講到的UV、PV崩潰率,卡頓也能夠有其對應的UV、PV卡頓率,UV就是Unique visitor,指的就是一臺手機客戶端爲一個訪客,00:00-24:00內相同的客戶端只被計算一次。而PV即Page View,即頁面瀏覽量或點擊量。因此UV、PV卡頓率的定義即爲以下所示:
// UV 卡頓率能夠評估卡頓的影響範圍
UV 卡頓率 = 發生過卡頓 UV / 開啓卡頓採集 UV
// PV 卡頓率評估卡頓的嚴重程度
PV 卡頓率 = 發生過卡頓 PV / 啓動採集 PV
複製代碼
由於卡頓問題的採樣規則跟內存問題是類似的,通常都是採起抽樣上報的方式,而且都應該按照單個用戶來抽樣。一個用戶若是命中採集,那麼在一天內都會持續的採集數據。
咱們能夠實現卡頓的火焰圖,即卡頓樹,在一張圖裏就能夠看到卡頓的總體信息。因爲卡頓的具體耗時跟手機性能,還有當時的使用場景、環境等密切相關,並且卡頓問題在日活大的應用上出現的場景很是多,因此對於大於咱們指定的卡頓閾值如1s\2s\3s時,咱們就能夠拋棄具體的耗時,只按照相同堆棧出現的比例來聚合各種卡頓信息。這樣咱們就可以很直觀地從卡頓樹上看到到底哪些堆棧出現的卡頓問題最多,以便於咱們可以優先去解決 Top 的卡頓問題,達到使用最少的精力獲取最大的優化效果的目的。
CPU Profiler的使用筆者已經在深刻探索Android啓動速度優化中詳細分析過了,若是對CPU Profiler還不是很熟悉的話,能夠去看看這篇文章。
下面咱們來簡單來回顧一下CPU Profiler。
運行時開銷嚴重,總體都會變慢,可能會帶偏咱們的優化方向。
Debug.startMethodTracing("");
// 須要檢測的代碼片斷
...
Debug.stopMethodTracing();
複製代碼
最終生成的生成文件在sd卡:Android/data/packagename/files。
systrace 利用了 Linux 的ftrace調試工具(ftrace是用於瞭解Linux內核內部運行狀況的調試工具),至關於在系統各個關鍵位置都添加了一些性能探針,也就是在代碼里加了一些性能監控的埋點。Android 在 ftrace 的基礎上封裝了atrace,並增長了更多特有的探針,好比Graphics、Activity Manager、Dalvik VM、System Server 等等。對於Systrace的使用筆者在深刻探索Android啓動速度優化這篇文章中已經詳細分析過了,若是對Systrace還不是很熟悉的話能夠去看看這篇文章。
下面咱們來簡單回顧一下Systrace。
監控和跟蹤API調用、線程運行狀況,生成HTML報告。
API 18以上使用,推薦使用TraceCompat。
使用python命令執行腳本,後面加上一系列參數,以下所示:
python systrace.py -t 10 [other-options] [categories]
// 筆者一般使用的systrace配置
python /Users/quchao/Library/Android/sdk/platform-tools/systrace/systrace.py -t 20 sched gfx view wm am app webview -a "com.wanandroid.json.chao" -o ~/Documents/open-project/systrace_data/wanandroid_start_1.html
複製代碼
具體參數含義以下:
最後,咱們還能夠經過編譯時給每一個函數插樁的方式來實現線下自動增長應用程序的耗時分析,可是要注意需過濾大部分的短函數,以減小性能損耗(這一點能夠經過黑名單配置的方式去過濾短函數或調用很是頻繁的函數)。使用這種方式咱們就能夠看到整個應用程序的調用流程。包括應用關鍵線程的函數調用,例如渲染耗時、線程鎖,GC 耗時等等。這裏可使用zhengcx的MethodTraceMan,可是目前僅僅能實現對包名和類名的過濾配置,因此須要對源碼進行定製化,以支持過濾短函數或調用很是頻繁函數的配置功能。
基於性能的考慮,若是要在線上使用此方案,最好只去監控主線程的耗時。雖然插樁方案對性能的影響並非很大,可是建議僅在線下或灰度環境中使用。
此外,若是你須要分析Native 函數的調用,請使用Android 5.0 新增的Simpleperf性能分析工具,它利用了 CPU 的性能監控單元(PMU)提供的硬件 perf 事件。使用 Simpleperf 能夠看到全部的 Native 代碼的耗時,對一些 Android 系統庫的調用,在分析問題時有比較大的幫助,例如分析加載 dex、verify class 的耗時等等。此外,在 Android Studio 3.2 中的 Profiler 也直接支持了 Simpleper(SampleNative性能分析工具 (API Level 26+)),這更加方便了native代碼的調試。
StrictMode是Android 2.3引入的一個工具類,它被稱爲嚴苛模式,是Android提供的一種運行時檢測機制,能夠用來幫助開發人員用來檢測代碼中一些不規範的問題。對於咱們的項目當中,可能會成千上萬行代碼,若是咱們用肉眼Review,這樣不只效率很是低效,並且比較容易出問題。使用StrictMode以後,系統會自動檢測出來在主線程中的一些異常狀況,並按照咱們的配置給出相應的反應。
StrictMode這個工具是很是強大的,可是咱們可能由於對它不熟悉而忽略掉它。StrictMode主要用來檢測兩大問題:
線程策略的檢測內容,是一些自定義的耗時調用、磁盤讀取操做以及網絡請求等。
虛擬機策略的檢測內容以下:
若是要在應用中使用StrictMode,只須要在Applicaitoin的onCreate方法中對StrictMode進行統一配置,代碼以下所示:
private void initStrictMode() {
// 一、設置Debug標誌位,僅僅在線下環境才使用StrictMode
if (DEV_MODE) {
// 二、設置線程策略
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectCustomSlowCalls() //API等級11,使用StrictMode.noteSlowCode
.detectDiskReads()
.detectDiskWrites()
.detectNetwork() // or .detectAll() for all detectable problems
.penaltyLog() //在Logcat 中打印違規異常信息
// .penaltyDialog() //也能夠直接跳出警報dialog
// .penaltyDeath() //或者直接崩潰
.build());
// 三、設置虛擬機策略
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
// 給NewsItem對象的實例數量限制爲1
.setClassInstanceLimit(NewsItem.class, 1)
.detectLeakedClosableObjects() //API等級11
.penaltyLog()
.build());
}
}
複製代碼
最後,在日誌輸出欄中注意使用「StrictMode」關鍵字過濾出對應的log便可。
Profilo是一個用於收集應用程序生產版本的性能跟蹤的Android庫。
對於Profilo來講,它集成了atrace功能,ftrace 全部的性能埋點數據都會經過 trace_marker 文件寫入到內核緩衝區,Profilo 使用了 PLT Hook 攔截了寫入操做,以選擇部分關心的事件去作特定的分析。這樣全部的 systrace 的探針咱們均可以拿到,例如四大組件生命週期、鎖等待時間、類校驗、GC 時間等等。不過大部分的 atrace 事件都比較籠統,從事件「B|pid|activityStart」,咱們沒法明確知道該事件具體是由哪一個 Activity 來建立的。
此外,使用Profilo還可以快速獲取Java堆棧。因爲獲取堆棧須要暫停主線程的運行,因此profilo經過間隔發送 SIGPROF 信號這樣一種相似 Native 崩潰捕捉的方式去快速獲取 Java 堆棧。
Profilo可以低耗時地快速獲取Java堆棧的具體實現原理爲當Signal Handler 捕獲到信號後,它就會獲取到當前正在執行的 Thread,經過 Thread 對象就能夠拿到當前線程的 ManagedStack,ManagedStack 是一個單鏈表,它保存了當前的 ShadowFrame 或者 QuickFrame 棧指針,先依次遍歷 ManagedStack 鏈表,而後遍歷其內部的 ShadowFrame 或者 QuickFrame 還原一個可讀的調用棧,從而 unwind 出當前的 Java 堆棧。關於ManagedStack與ShadowFrame、QuickFrame三者的關係以下圖所示:
Profilo經過這種方式,就能夠實現線程同步運行的同時,咱們還能夠去幫它作檢查,而且耗時基本能夠忽略不計。可是目前 Profilo 快速獲取堆棧的功能不支持 Android 8.0 和 Android 9.0,而且它內部使用了Hook等大量的黑科技手段,鑑於穩定性問題,建議採起抽樣部分用戶的方式來開啓該功能。
前面咱們說過,Profilo最終也使用了ftrace,而Systrace主要也是根據Linux的ftrace機制來實現的,而ftrace的做用是幫助咱們瞭解 Linux 內核的運行時行爲,以便進行故障調試或性能分析。ftrace的總體架構以下所示:
由上圖可知,Ftrace 有兩大組成部分,一個是 framework,另外就是一系列的 tracer 。每一個 tracer 用於完成不一樣的功能,而且它們統一由 framework 管理。 ftrace 的 trace 信息保存在 ring buffer 中,由 framework 負責管理。 Framework 利用 debugfs 系統在 /debugfs 下創建 tracing 目錄,並提供了一系列的控制文件。
下面,我這裏給出使用 PLTHook 技術來獲取 Atrace 日誌的一個項目。
使用 PLTHook 技術來獲取 Atrace 的日誌-項目地址
運行項目後,咱們點擊按鈕開啓Atrace日誌,而後就能夠在Logcat中看到以下的native層日誌信息:
2020-02-05 10:58:00.873 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ===============install systrace hoook==================
2020-02-05 10:58:00.879 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|inflate
2020-02-05 10:58:00.880 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|LinearLayout
2020-02-05 10:58:00.881 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.882 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|TextView
2020-02-05 10:58:00.884 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.885 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.888 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|notifyFramePending
2020-02-05 10:58:00.888 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.889 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|Choreographer#doFrame
2020-02-05 10:58:00.889 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|input
2020-02-05 10:58:00.889 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.889 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|traversal
2020-02-05 10:58:00.889 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|draw
2020-02-05 10:58:00.890 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|Record View#draw()
2020-02-05 10:58:00.891 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.891 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|DrawFrame
2020-02-05 10:58:00.891 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|syncFrameState
2020-02-05 10:58:00.891 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|prepareTree
2020-02-05 10:58:00.891 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.891 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.891 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|query
2020-02-05 10:58:00.891 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.891 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.891 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|query
2020-02-05 10:58:00.891 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.892 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|query
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|query
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.892 13052-13052/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|query
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|query
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|setBuffersDimensions
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.892 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|dequeueBuffer
2020-02-05 10:58:00.894 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|importBuffer
2020-02-05 10:58:00.894 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|HIDL::IMapper::importBuffer::passthrough
2020-02-05 10:58:00.894 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.894 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.894 13052-13058/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|Compiling
2020-02-05 10:58:00.894 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= E
2020-02-05 10:58:00.894 13052-13075/com.dodola.atrace I/HOOOOOOOOK: ========= B|13052|query
複製代碼
須要注意的是,日誌中的B表明begin,也就是對應時間開始的時間,而E表明End,即對應事件結束的時間,而且,B|事件和E|事件是成對出現的,這樣咱們就能夠經過該事件的結束時間減去對應的開始時間來得到每一個事件使用的時間。例如,上述log中咱們能夠看出TextView的draw方法顯示使用了3ms。
此外,在下面這個項目裏展現瞭如何使用 PLTHook 技術來獲取線程建立的堆棧。
運行項目後,咱們點擊開啓 Thread Hook按鈕,而後點擊新建 Thread按鈕。最後能夠在Logcat 中看到Thread建立的堆棧信息:
2020-02-05 13:47:59.006 20159-20159/com.dodola.thread E/HOOOOOOOOK: stack:com.dodola.thread.ThreadHook.getStack(ThreadHook.java:16)
com.dodola.thread.MainActivity$2.onClick(MainActivity.java:40)
android.view.View.performClick(View.java:6311)
android.view.View$PerformClick.run(View.java:24833)
android.os.Handler.handleCallback(Handler.java:794)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loop(Looper.java:173)
android.app.ActivityThread.main(ActivityThread.java:6653)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:821)
2020-02-05 13:47:59.007 20159-20339/com.dodola.thread E/HOOOOOOOOK: thread name:Thread-2
2020-02-05 13:47:59.008 20159-20339/com.dodola.thread E/HOOOOOOOOK: thread id:1057
2020-02-05 13:47:59.009 20159-20339/com.dodola.thread E/HOOOOOOOOK: stack:com.dodola.thread.ThreadHook.getStack(ThreadHook.java:16)
com.dodola.thread.MainActivity$2$1.run(MainActivity.java:38)
2020-02-05 13:47:59.011 20159-20340/com.dodola.thread E/HOOOOOOOOK: inner thread name:Thread-3
2020-02-05 13:47:59.012 20159-20340/com.dodola.thread E/HOOOOOOOOK: inner thread id:1058
複製代碼
因爲Profilo與PLT Hook涉及了大量的C/C++、NDK開發的知識,限於篇幅,因此這部分不作詳細講解,如對NDK開發感興趣的同窗能夠期待下我後面的Awesome-Android-NDK系列文章,等性能優化系列文章更新完畢以後,就會開始去系統地學習NDK相關的開發知識,敬請期待。
主要有一下兩點緣由:
它的原理源於Android的消息處理機制,一個線程無論有多少Handler,它只會有一個Looper存在,主線程執行的任何代碼都會經過Looper.loop()方法執行。而在Looper函數中,它有一個mLogging對象,這個對象在每一個message處理先後都會被調用。主線程發生了卡頓,那必定是在dispatchMessage()方法中執行了耗時操做。那麼,咱們就能夠經過這個mLogging對象對dispatchMessage()進行監控。
首先,咱們看下Looper用於執行消息循環的loop()方法,關鍵代碼以下所示:
/**
* Run the message queue in this thread. Be sure to call
* {@link #quit()} to end the loop.
*/
public static void loop() {
...
for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}
// This must be in a local variable, in case a UI event sets the logger
final Printer logging = me.mLogging;
if (logging != null) {
// 1
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
...
try {
// 2
msg.target.dispatchMessage(msg);
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} finally {
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}
...
if (logging != null) {
// 3
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
複製代碼
在Looper的loop()方法中,在其執行每個消息(註釋2處)的先後都由logging進行了一次打印輸出。能夠看到,在執行消息前是輸出的">>>>> Dispatching to ",在執行消息後是輸出的"<<<<< Finished to ",它們打印的日誌是不同的,咱們就能夠由此來判斷消息執行的先後時間點。
因此,具體的實現能夠概括爲以下步驟:
它是一個非侵入式的性能監控組件,能夠經過通知的形式彈出卡頓信息。它的原理就是咱們剛剛講述到的卡頓監控的實現原理。
接下咱們經過一個簡單的示例來說解一下它的使用。
首先,咱們須要在moudle的build.gradle下配置它的依賴,以下所示:
// release:項目中實現了線上監控體系的時候去使用
api 'com.github.markzhai:blockcanary-android:1.5.0'
// 僅在debug包啓用BlockCanary進行卡頓監控和提示的話,能夠這麼用
debugApi 'com.github.markzhai:blockcanary-android:1.5.0'
releaseApi 'com.github.markzhai:blockcanary-no-op:1.5.0'
複製代碼
其次,在Application的onCreate方法中開啓卡頓監控:
// 注意在主進程初始化調用
BlockCanary.install(this, new AppBlockCanaryContext()).start();
複製代碼
最後,繼承BlockCanaryContext類去實現本身的監控配置上下文類:
public class AppBlockCanaryContext extends BlockCanaryContext {
// 實現各類上下文,包括應用標識符,用戶uid,網絡類型,卡頓判斷闕值,Log保存位置等等
/**
* 提供應用的標識符
*
* @return 標識符可以在安裝的時候被指定,建議爲 version + flavor.
*/
public String provideQualifier() {
return "unknown";
}
/**
* 提供用戶uid,以便在上報時可以將對應的
* 用戶信息上報至服務器
*
* @return user id
*/
public String provideUid() {
return "uid";
}
/**
* 提供當前的網絡類型
*
* @return {@link String} like 2G, 3G, 4G, wifi, etc.
*/
public String provideNetworkType() {
return "unknown";
}
/**
* 配置監控的時間區間,超過這個時間區間 ,BlockCanary將會中止, use
* with {@code BlockCanary}'s isMonitorDurationEnd
*
* @return monitor last duration (in hour)
*/
public int provideMonitorDuration() {
return -1;
}
/**
* 指定斷定爲卡頓的閾值threshold (in millis),
* 你能夠根據不一樣設備的性能去指定不一樣的閾值
*
* @return threshold in mills
*/
public int provideBlockThreshold() {
return 1000;
}
/**
* 設置線程堆棧dump的間隔, 當阻塞發生的時候使用, BlockCanary 將會根據
* 當前的循環週期在主線程去dump堆棧信息
* <p>
* 因爲依賴於Looper的實現機制, 真實的dump週期
* 將會比設定的dump間隔要長(尤爲是當CPU很繁忙的時候).
* </p>
*
* @return dump interval (in millis)
*/
public int provideDumpInterval() {
return provideBlockThreshold();
}
/**
* 保存log的路徑, 好比 "/blockcanary/", 若是權限容許的話,
* 會保存在本地sd卡中
*
* @return path of log files
*/
public String providePath() {
return "/blockcanary/";
}
/**
* 是否須要通知去通知用戶發生阻塞
*
* @return true if need, else if not need.
*/
public boolean displayNotification() {
return true;
}
/**
* 用於將多個文件壓縮爲一個.zip文件
*
* @param src files before compress
* @param dest files compressed
* @return true if compression is successful
*/
public boolean zip(File[] src, File dest) {
return false;
}
/**
* 用於將已經被壓縮好的.zip log文件上傳至
* APM後臺
*
* @param zippedFile zipped file
*/
public void upload(File zippedFile) {
throw new UnsupportedOperationException();
}
/**
* 用於設定包名, 默認使用進程名,
*
* @return null if simply concern only package with process name.
*/
public List<String> concernPackages() {
return null;
}
/**
* 使用 @{code concernPackages}方法指定過濾的堆棧信息
*
* @return true if filter, false it not.
*/
public boolean filterNonConcernStack() {
return false;
}
/**
* 指定一個白名單, 在白名單的條目將不會出如今展現阻塞信息的UI中
*
* @return return null if you don't need white-list filter.
*/
public List<String> provideWhiteList() {
LinkedList<String> whiteList = new LinkedList<>();
whiteList.add("org.chromium");
return whiteList;
}
/**
* 使用白名單的時候,是否去刪除堆棧在白名單中的文件
*
* @return true if delete, false it not.
*/
public boolean deleteFilesInWhiteList() {
return true;
}
/**
* 阻塞攔截器, 咱們能夠指定發生阻塞時應該作的工做
*/
public void onBlock(Context context, BlockInfo blockInfo) {
}
}
複製代碼
能夠看到,在上述配置中,咱們指定了卡頓的閾值爲1000ms。接下來,咱們能夠測試一下BlockCanary監測卡頓時的效果,這裏我在Activity的onCreate方法中添加以下代碼使線程休眠3s:
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
複製代碼
而後,咱們運行項目,打開App,便可看到相似LeakCanary界面那樣的卡頓信息堆棧。
除了發生卡頓時BlockCanary提供的圖形界面可供開發和測試人員直接查看卡頓緣由以外。其最大的做用仍是在線上環境或者自動化monkey測試的環節進行大範圍的log採集與分析,對於分析的緯度,能夠從如下兩個緯度來進行:
在卡頓的週期以內,應用確實發生了卡頓,可是獲取到的卡頓信息可能會不許確,和咱們的OOM同樣,也就是最後的堆棧信息僅僅只是一個表象,並非真正發生問題時的一個堆棧。下面,咱們先看下以下的一個示意圖:
假設主線程在T1到T2的時間段內發生了卡頓,卡頓檢測方案獲取卡頓時的堆棧信息是T2時刻,可是實際上發生卡頓的時刻多是在這段時間區域內另外一個耗時過長的函數,那麼可能在咱們捕獲卡頓的時刻時,真正的卡頓時機已經執行完成了,因此在T2時刻捕獲到的一個卡頓信息並不可以反映卡頓的現場,也就是最後呈現出來的堆棧信息僅僅只是一個表象,並非真正問題的藏身之處。
咱們能夠獲取卡頓週期內的多個堆棧,而不只僅是最後一個,這樣的話,若是發生了卡頓,咱們就能夠根據這些堆棧信息來清晰地還原整個卡頓現場。由於咱們有卡頓現場的多個堆棧信息,咱們徹底知道卡頓時究竟發生了什麼,到底哪些函數它的調用時間比較長。接下來,咱們看看下面的卡頓檢測優化流程圖:
根據圖中,能夠梳理出優化後的具體實現步驟爲:
經過上述的優化,咱們就能夠知道在整個卡頓週期以內,到底是哪些方法在執行,哪些方法比較耗時。
可是這種海量卡頓堆棧的處理又存在着另外一個問題,那就是高頻卡頓上報量太大,服務器壓力較大,這裏咱們來分析下如何減小服務端對堆棧信息的處理量。
在出現卡頓的狀況下,咱們採集到了多個堆棧,大機率的狀況下,可能會存在多個重複的堆棧,而這個重複的堆棧信息纔是咱們應該關注的地方。咱們能夠對一個卡頓下的堆棧進行能hash排重,找出重複的堆棧。這樣,服務器須要處理的數據量就會大大減小,同時也過濾出了咱們須要重點關注的對象。對於開發人員來講,就能更快地找到卡頓的緣由。
在本節中,咱們學習了自動化卡頓檢測的原理,而後,咱們使用這種方案進行了實戰,最後,我還介紹了這種方案的問題和它的優化思路。
在本篇文章中,咱們主要對卡頓優化分析方法與工具 、自動化卡頓檢測方案及優化相關的知識進行了全面且深刻地講解,這裏再簡單總結一下本篇文章涉及的兩大主題:
下篇,筆者將帶領你們更加深刻地去學習卡頓優化的相關知識,敬請期待~
一、國內Top團隊大牛帶你玩轉Android性能分析與優化 第6章 卡頓優化
三、《Android移動性能實戰》第四章 CPU
四、《Android移動性能實戰》第七章 流暢度
五、Android dumpsys cpuinfo 信息解讀
七、nanoscope-An extremely accurate Android method tracing tool
九、lancet-A lightweight and fast AOP framework for Android App and SDK developers
十、MethodTraceMan-用於快速找到高耗時方法,定位解決Android App卡頓問題
十二、使用 ftrace
1三、profilo-A library for performance traces from production
1四、ftrace 簡介
1五、atrace源碼
1六、AndroidAdvanceWithGeektime / Chapter06
1七、AndroidAdvanceWithGeektime / Chapter06-plus
歡迎關注個人微信:
bcce5360
因爲微信人數超過200人只能邀請入羣,因此,麻煩你們想進微信羣的朋友們,加我微信拉你進羣。
2千人QQ羣,Awesome-Android學習交流羣,QQ羣號:959936182, 歡迎你們加入~