深刻探索 Android 內存優化(煉獄級別-上)

前言

成爲一名優秀的Android開發,須要一份完備的知識體系,在這裏,讓咱們一塊兒成長爲本身所想的那樣~。

本篇是 Android 內存優化的進階篇,難度能夠說達到了煉獄級別,建議對內存優化不是很是熟悉的仔細看看前篇文章: Android性能優化以內存優化,其中詳細分析瞭如下幾大模塊:php

  • 1)、Android的內存管理機制
  • 2)、優化內存的意義
  • 3)、避免內存泄漏
  • 4)、優化內存空間
  • 5)、圖片管理模塊的設計與實現

若是你對以上基礎內容都比較瞭解了,那麼咱們便開始 Android 內存優化的探索之旅吧。html

本篇文章很是長,建議收藏後慢慢享用~前端

目錄

  • 1、重識內存優化
    • 一、手機RAM
    • 二、內存優化的緯度
    • 三、內存問題
  • 2、常見工具選擇
    • 一、Memory Profiler
    • 二、Memory Analyzer
    • 三、LeakCanary
  • 3、Android內存管理機制回顧
    • 一、Java 內存分配
    • 二、Java 內存回收算法
    • 三、Android 內存管理機制
    • 四、小結
  • 4、內存抖動
    • 一、那麼,爲何內存抖動會致使 OOM?
    • 二、內存抖動解決實戰
    • 三、內存抖動常見案例
  • 5、內存優化體系化搭建
    • 一、MAT回顧
    • 二、搭建體系化的圖片優化 / 監控機制
    • 三、創建線上應用內存監控體系
    • 四、創建全局的線程監控組件
    • 五、GC 監控組件搭建
    • 六、創建線上 OOM 監控組件:Probe
    • 七、實現 單機版 的 Profile Memory 自動化內存分析
    • 八、搭建線下 Native 內存泄漏監控體系
    • 九、設置內存兜底策略
    • 十、更深刻的內存優化策略
  • 6、內存優化演進
    • 一、自動化測試階段
    • 二、LeakCanary
    • 三、使用基於 LeakCannary 的改進版 ResourceCanary
  • 7、內存優化工具
    • 一、top
    • 二、dumpsys meminfo
    • 三、LeakInspector
    • 四、JHat
    • 五、ART GC Log
    • 六、Chrome Devtool
  • 8、內存問題總結
    • 一、內類是有危險的編碼方式
    • 二、普通 Hanlder 內部類的問題
    • 三、登陸界面的內存問題
    • 四、使用系統服務時產生的內存問題
    • 五、把 WebView 類型的泄漏裝進垃圾桶進程
    • 六、在適當的時候對組件進行註銷
    • 七、Handler / FrameLayout 的 postDelyed 方法觸發的內存問題
    • 八、圖片放錯資源目錄也會有內存問題
    • 九、列表 item 被回收時注意釋放圖片的引用
    • 十、使用 ViewStub 進行佔位
    • 十一、注意定時清理 App 過期的埋點數據
    • 十二、針對匿名內部類 Runnable 形成內存泄漏的處理
  • 9、內存優化常見問題
    • 一、大家內存優化項目的過程是怎麼作的?
    • 二、你作了內存優化最大的感覺是什麼?
    • 三、如何檢測全部不合理的地方?
  • 10、總結
    • 一、優化大方向
    • 二、優化細節
    • 三、內存優化體系化建設總結

1、重識內存優化

Android給每一個應用進程分配的內存都是很是有限的,那麼,爲何不能把圖片下載下來都放到磁盤中呢?那是由於放在 內存 中,展現會更 「」,快的緣由有兩點,以下所示:java

  • 1)、硬件快:內存自己讀取、存入速度快。
  • 2)、複用快:解碼成果有效保存,複用時,直接使用解碼後對象,而不是再作一次圖像解碼。

這裏說一下解碼的概念。Android系統要在屏幕上展現圖片的時候只認 「像素緩衝」,而這也是大多數操做系統的特徵。而咱們 常見的jpg,png等圖片格式,都是把 「像素緩衝」 使用不一樣的手段壓縮後的結果,因此這些格式的圖片,要在設備上 展現,就 必須通過一次解碼,它的 執行速度會受圖片壓縮比、尺寸等因素影響。(官方建議:把從內存中淘汰的圖片,下降壓縮比後存儲到本地,以備後用,這樣能夠最大限度地下降之後複用時的解碼開銷。)linux

下面,咱們來了解一下內存優化的一些重要概念。android

一、手機RAM

手機不使用 PCDDR內存,採用的是 LPDDR RAM,即 」低功耗雙倍數據速率內存「。其計算規則以下所示:git

LPDDR系列的帶寬 = 時鐘頻率 ✖️內存總線位數 / 8
LPDDR4 = 1600MHZ ✖️64 / 8 ✖️雙倍速率 = 25.6GB/s。
複製代碼

那麼內存佔用是否越少越好?

當系統 內存充足 的時候,咱們能夠 多用 一些得到 更好的性能。當系統 內存不足 的時候,咱們但願能夠作到 」用時分配,及時釋放「。github

二、內存優化的緯度

對於Android內存優化來講又能夠細分爲以下兩個維度,以下所示:web

  • 1)、RAM優化
  • 2)、ROM優化

一、RAM優化

主要是 下降運行時內存。它的 目的 有以下三個:算法

  • 1)、防止應用發生OOM
  • 2)、下降應用因爲內存過大被LMK機制殺死的機率
  • 3)、避免不合理使用內存致使GC次數增多,從而致使應用發生卡頓

二、ROM優化

下降應用佔ROM的體積,進行APK瘦身。它的 目的 主要是爲了 下降應用佔用空間,避免因ROM空間不足致使程序沒法安裝

三、內存問題

那麼,內存問題主要是有哪幾類呢?內存問題一般來講,能夠細分爲以下 三類:

  • 1)、內存抖動
  • 2)、內存泄漏
  • 3)、內存溢出

下面,咱們來了解下它們。

一、內存抖動

內存波動圖形呈 鋸齒張GC致使卡頓

這個問題在 Dalvik虛擬機 上會 更加明顯,而 ART虛擬機內存管理跟回收策略 上都作了 大量優化內存分配和GC效率相比提高了5~10倍,因此 出現內存抖動的機率會小不少

二、內存泄漏

Android系統虛擬機的垃圾回收是經過虛擬機GC機制來實現的。GC會選擇一些還存活的對象做爲內存遍歷的根節點GC Roots,經過對GC Roots的可達性來判斷是否須要回收。內存泄漏就是 在當前應用週期內再也不使用的對象被GC Roots引用,致使不能回收,使實際可以使用內存變小。簡言之,就是 對象被持有致使沒法釋放或不能按照對象正常的生命週期進行釋放。通常來講,可用內存減小、頻繁GC,容易致使內存泄漏

三、內存溢出

即OOM,OOM時會致使程序異常。Android設備出廠之後,java虛擬機對單個應用的最大內存分配就肯定下來了,超出這個值就會OOM。單個應用可用的最大內存對應於 /system/build.prop 文件中的 dalvik.vm.heapgrowthlimit

此外,除了因內存泄漏累積到必定程度致使OOM的狀況之外,也有一次性申請不少內存,好比說 一次建立大的數組或者是載入大的文件如圖片的時候會致使OOM。並且,實際狀況下 不少OOM就是因圖片處理不當 而產生的。

2、常見工具選擇

Android性能優化以內存優化 中咱們已經介紹過了相關的優化工具,這裏再簡單回顧一下。

一、Memory Profiler

做用

  • 1)、實時圖表展現應用內存使用量
  • 2)、用於識別內存泄漏、抖動等
  • 3)、提供捕獲堆轉儲、強制GC以及根據內存分配的能力

優勢

  • 1)、方便直觀
  • 2)、線下使用

二、Memory Analyzer

強大的 Java Heap 分析工具,查找 內存泄漏及內存佔用, 生成 總體報告分析內存問題 等等。建議 線下深刻使用

三、LeakCanary

自動化 內存泄漏檢測神器。建議僅用於線下集成

它的 缺點 比較明顯,具體有以下兩點:

  • 1)、雖然使用了 idleHandler與多進程,可是 dumphprof 的 SuspendAll Thread 的特性依然會致使應用卡頓
  • 2)、在三星等手機,系統會緩存最後一個Activity,此時應該採用更嚴格的檢測模式

3、Android內存管理機制回顧

ART 和 Dalvik 虛擬機使用 分頁和內存映射 來管理內存。下面咱們先從Java的內存分配開始提及。

一、Java 內存分配

Java的 內存分配區域 分爲以下 五部分

  • 1)、方法區:主要存放靜態常量
  • 2)、虛擬機棧:Java變量引用
  • 3)、本地方法棧:native變量引用
  • 4)、堆:對象
  • 5)、程序計數器:計算當前線程的當前方法執行到多少行

二、Java 內存回收算法

一、標記-清除算法

流程可簡述爲 兩步

  • 1)、標記全部須要回收的對象
  • 2)、統一回收全部被標記的對象

優勢

實現比較簡單。

缺點

  • 1)、標記、清除效率不高
  • 2)、產生大量內存碎片

二、複製算法

流程可簡述爲 三步

  • 1)、將內存劃分爲大小相等的兩塊
  • 2)、一塊內存用完以後複製存活對象到另外一塊
  • 3)、清理另外一塊內存

優勢

實現簡單,運行高效,每次僅需遍歷標記一半的內存區域

缺點

浪費一半的空間,代價大。

三、標記-整理算法

流程可簡述爲 三步

  • 1)、標記過程與 標記-清除算法 同樣
  • 2)、存活對象往一端進行移動
  • 3)、清理其他內存

優勢

  • 1)、避免 標記-清除 致使的內存碎片
  • 2)、避免複製算法的空間浪費

四、分代收集算法

如今 主流的虛擬機 通常用的比較多的仍是分代收集算法,它具備以下 特色

  • 1)、結合多種算法優點
  • 2)、新生代對象存活率低,使用 複製算法
  • 3)、老年代對象存活率高,使用 標記-整理算法

三、Android 內存管理機制

Android 中的內存是 彈性分配 的,分配值 與 最大值 受具體設備影響

對於 OOM場景 其實能夠細分爲以下兩種:

  • 1)、內存真正不足
  • 2)、可用(被分配的)內存不足

咱們須要着重注意一下這兩種的區分。

四、小結

以Android中虛擬機的角度來講,咱們要清楚 Dalvik 與 ART 區別Dalvik 僅固定一種回收算法,而 ART 回收算法可在 運行期按需選擇,而且,ART 具有 內存整理 能力,減小內存空洞

最後,LMK(Low Memory killer) 機制保證了進程資源的合理利用,它的實現原理主要是 根據進程分類和回收收益來綜合決定的一套算法集

4、內存抖動

內存頻繁分配和回收 致使內存 不穩定,就會出現內存抖動,它一般表現爲 頻繁GC、內存曲線呈鋸齒狀

而且,它的危害也很嚴重,一般會致使 頁面卡頓,甚至形成 OOM

一、那麼,爲何內存抖動會致使 OOM?

主要緣由有以下兩點:

  • 1)、頻繁建立對象,致使內存不足及碎片(不連續)
  • 2)、不連續的內存片沒法被分配,致使OOM

二、內存抖動解決實戰

這裏咱們假設有這樣一個場景:點擊按鈕使用 handler 發送一個空消息,handler 的 handleMessage 接收到消息後建立內存抖動,即在 for 循環建立 100個容量爲10萬 的 strings 數組並在 30ms 後繼續發送空消息。

通常使用 Memory Profiler (表現爲 頻繁GC、內存曲線呈鋸齒狀)結合代碼排查便可找到內存抖動出現的地方。

一般的技巧就是着重查看 循環或頻繁被調用 的地方。

三、內存抖動常見案例

下面列舉一些致使內存抖動的常見案例,以下所示:

一、字符串使用加號拼接

  • 1)、使用StringBuilder替代
  • 2)、初始化時設置容量,減小StringBuilder的擴容

二、資源複用

  • 1)、使用 全局緩存池,以 重用頻繁申請和釋放的對象
  • 2)、注意 結束 使用後,須要 手動釋放對象池中的對象

三、減小不合理的對象建立

  • 1)、ondraw、getView 中建立的對象儘可能進行復用
  • 2)、避免在循環中不斷建立局部變量

四、使用合理的數據結構

使用 SparseArray類族、ArrayMap 來替代 HashMap

5、內存優化體系化搭建

在開始咱們今天正式的主題以前,咱們先來回歸一下內存泄漏的概念與解決技巧。

所謂的內存泄漏就是 內存中存在已經沒有用的對象。它的 表現 通常爲 內存抖動、可用內存逐漸減小。 它的 危害 即會致使 內存不足、GC頻繁、OOM

而對於 內存泄漏的分析 通常可簡述爲以下 兩步

  • 1)、使用 Memory Profiler 初步觀察
  • 2)、經過 Memory Analyzer 結合代碼確認

一、MAT回顧

MAT查找內存泄漏

對於MAT來講,其常規的查找內存泄漏的方式能夠細分爲以下三步:

  • 1)、首先,找到當前 Activity,在 Histogram 中選擇其 List Objects 中的 with incoming reference(哪些引用引向了我)
  • 2)、而後,選擇當前的一個 Path to GC Roots/Merge to GC Roots 的 exclude All 弱軟虛引用
  • 3)、最後,找到的泄漏對象在左下角下會有一個小圓圈

此外,在 Android性能優化以內存優化 還有幾種進階的使用方式,這裏就不一一贅述了,下面,咱們來看看關於 MAT 使用時的一些關鍵細節。

MAT的關鍵使用細節

要全面掌握MAT的用法,必需要先了解 隱藏在 MAT 使用中的四大細節,以下所示:

  • 1)、善於使用 Regex 查找對應泄漏類
  • 2)、使用 group by package 查找對應包下的具體類
  • 3)、明白 with outgoing references 和 with incoming references 的區別
    • with outgoing references:它引用了哪些對象
    • with incoming references:哪些對象引用了它
  • 4)、瞭解 Shallow Heap 和 Retained Heap 的區別
    • Shallow Heap:表示對象自身佔用的內存
    • Retained Heap:對象自身佔用的內存 + 對象引用的對象所佔用的內存

MAT 關鍵組件回顧

除此以外,MAT 共有 5個關鍵組件 幫助咱們去分析內存方面的問題,分別以下所示:

  • 1)、Dominator_tree
  • 2)、Histogram
  • 3)、thread_overview
  • 4)、Top Consumers
  • 5)、Leak Suspects

下面咱們這裏再簡單地回顧一下它們。

一、Dominator(支配者):

若是從GC Root到達對象A的路徑上必須通過對象B,那麼B就是A的支配者。

二、Histogram和dominator_tree的區別:

  • 1)、Histogram 顯示 Shallow Heap、Retained Heap、Objects,而 dominator_tree 顯示的是 Shallow Heap、Retained Heap、Percentage
  • 2)、Histogram 基於 的角度,dominator_tree是基於 實例 的角度。Histogram 不會具體顯示每個泄漏的對象,而dominator_tree會

三、thread_overview

查看 線程數量線程的 Shallow Heap、Retained Heap、Context Class Loader 與 is Daemon

四、Top Consumers

經過 圖形 的形式列出 佔用內存比較多的對象

在下方的 Biggest Objects 還能夠查看其 相對比較詳細的信息,例如 Shallow Heap、Retained Heap

五、Leak Suspects

列出有內存泄漏的地方,點擊 Details 能夠查看其產生內存泄漏的引用鏈

二、搭建體系化的圖片優化 / 監控機制

在介紹圖片監控體系的搭建以前,首先咱們來回顧下 Android Bitmap 內存分配的變化

Android Bitmap 內存分配的變化

在Android 3.0以前

  • 1)、Bitmap 對象存放在 Java Heap,而像素數據是存放在 Native 內存中的
  • 2)、若是不手動調用 recycle,Bitmap Native 內存的回收徹底依賴 finalize 函數回調,可是回調時機是不可控的

Android 3.0 ~ Android 7.0

Bitmap對象像素數據 統一放到 Java Heap 中,即便不調用 recycle,Bitmap 像素數據也會隨着對象一塊兒被回收。

可是,Bitmap 所有放在 Java Heap 中的缺點很明顯,大體有以下兩點:

  • 1)、Bitmap是內存消耗的大戶,而 Max Java Heap 通常限制爲 25六、512MB,Bitmap 過大過多容易致使 OOM
  • 2)、容易引發大量 GC,沒有充分利用系統的可用內存

Android 8.0及之後

  • 1)、使用了可以輔助回收 Native 內存的 NativeAllocationRegistry,以實現將像素數據放到 Native 內存中,而且能夠和 Bitmap 對象一塊兒快速釋放,最後,在 GC 的時候還能夠考慮到這些 Bitmap 內存以防止被濫用
  • 2)、Android 8.0 爲了 解決圖片內存佔用過多和圖像繪製效率過慢 的問題新增了 硬件位圖 Hardware Bitmap

那麼,咱們如何將圖片內存存放在 Native 中呢?

將圖片內存存放在Native中的步驟有 四步,以下所示:

  • 1)、調用 libandroid_runtime.so 中的 Bitmap 構造函數,申請一張空的 Native Bitmap。對於不一樣 Android 版本而言,這裏的獲取過程都有一些差別須要適配
  • 2)、申請一張普通的 Java Bitmap
  • 3)、將 Java Bitmap 的內容繪製到 Native Bitmap 中
  • 4)、釋放 Java Bitmap 內存

咱們都知道的是,當 系統內存不足 的時候,LMK 會根據 OOM_adj 開始殺進程,從 後臺、桌面、服務、前臺,直到手機重啓。而且,若是頻繁申請釋放 Java Bitmap 也很容易致使內存抖動。對於這種種問題,咱們該 如何評估內存對應用性能的影響 呢?

對此,咱們能夠主要從如下 兩個方面 進行評估,以下所示:

  • 1)、崩潰中異常退出和 OOM 的比例
  • 2)、低內存設備更容易出現內存不足和卡頓,須要查看應用中用戶的手機內存在 2GB 如下所佔的比例

對於具體的優化策略與手段,咱們能夠從如下 七個方面 來搭建一套 成體系化的圖片優化 / 監控機制

一、統一圖片庫

在項目中,咱們須要 收攏圖片的調用,避免使用 Bitmap.createBitmap、BitmapFactory 相關的接口建立 Bitmap,而應該使用本身的圖片框架

二、設備分級優化策略

內存優化首先須要根據 設備環境 來綜合考慮,讓高端設備使用更多的內存,作到 針對設備性能的好壞使用不一樣的內存分配和回收策略

所以,咱們可使用相似 device-year-class 的策略對設備進行分級,對於低端機用戶能夠關閉複雜的動畫或」重功能「,使用565格式的圖片或更小的緩存內存 等等。

業務開發人員須要 考慮功能是否對低端機開啓,在系統資源不夠時主動去作降級處理

三、創建統一的緩存管理組件

創建統一的緩存管理組件(參考 ACache),併合理使用 OnTrimMemory / LowMemory 回調,根據系統不一樣的狀態去釋放相應的緩存與內存

在實現過程當中,須要 解決使用 static LRUCache 來緩存大尺寸 Bitmap 的問題

而且,在經過實際的測試後,發現 onTrimMemory 的 ComponetnCallbacks2.TRIM_MEMORY_COMPLETE 並不等價於 onLowMemory,所以建議仍然要去監聽 onLowMemory 回調

四、低端機避免使用多進程

一個 空進程 也會佔用 10MB 內存,低端機應該儘量減小使用多進程。

針對低端機用戶能夠推出 4MB 的輕量級版本,現在日頭條極速版、Facebook Lite。

五、線下大圖片檢測

在開發過程當中,若是檢測到不合規的圖片使用(如圖片寬度超過View的寬度甚至屏幕寬度),應該馬上提示圖片所在的Activity和堆棧,讓開發人員更快發現並解決問題。在灰度和線上環境,能夠將異常信息上報到後臺,還能夠計算超寬率(圖片超過屏幕大小所佔圖片總數的比例)

下面,咱們介紹下如何實現對大圖片的檢測。

常規實現

繼承 ImageView,重寫實現計算圖片大小。可是侵入性強,而且不通用。

所以,這裏咱們介紹一種更好的方案:ARTHook。

ARTHook優雅檢測大圖

ARTHook,即 掛鉤,用額外的代碼勾住原有的方法,以修改執行邏輯,主要能夠用於如下四個方面:

  • 1)、AOP編程
  • 2)、運行時插樁
  • 3)、性能分析
  • 4)、安全審計

具體咱們是使用 Epic 來進行 Hook,Epic 是 一個虛擬機層面,以 Java 方法爲粒度的運行時 Hook 框架。簡單來講,它就是 ART 上的 Dexposed,而且它目前 支持 Android 4.0~10.0

Epic github 地址

使用步驟

Epic一般的使用步驟爲以下三個步驟:

一、在項目 moudle 的 build.gradle 中添加

compile 'me.weishu:epic:0.6.0'
複製代碼

二、繼承 XC_MethodHook,實現 Hook 方法先後的邏輯。如 監控Java線程的建立和銷燬

class ThreadMethodHook extends XC_MethodHook{
    @Override
    protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
        super.beforeHookedMethod(param);
        Thread t = (Thread) param.thisObject;
        Log.i(TAG, "thread:" + t + ", started..");
    }

    @Override
    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        Thread t = (Thread) param.thisObject;
        Log.i(TAG, "thread:" + t + ", exit..");
    }
}
複製代碼

三、注入 Hook 好的方法:

DexposedBridge.findAndHookMethod(Thread.class, "run", new ThreadMethodHook());
複製代碼

知道了 Epic 的基本使用方法以後,咱們即可以利用它來實現大圖片的監控報警了。

項目實戰

Awesome-WanAndroid 項目爲例,首先,在 WanAndroidApp 的 onCreate 方法中添加以下代碼:

DexposedBridge.hookAllConstructors(ImageView.class, new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
            super.afterHookedMethod(param);
        // 1
        DexposedBridge.findAndHookMethod(ImageView.class, "setImageBitmap", Bitmap.class, new ImageHook());
        }
    });
    
    
複製代碼

在註釋1處,咱們 經過調用 DexposedBridge 的 findAndHookMethod 方法找到全部經過 ImageView 的 setImageBitmap 方法設置的切入點,其中最後一個參數 ImageHook 對象是繼承了 XC_MethodHook 類,其目的是爲了 重寫 afterHookedMethod 方法拿到相應的參數進行監控邏輯的判斷

接下來,咱們來實現咱們的 ImageHook 類,代碼以下所示:

public class ImageHook extends XC_MethodHook {

    @Override
    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        // 1
        ImageView imageView = (ImageView) param.thisObject;
        checkBitmap(imageView,((ImageView) param.thisObject).getDrawable());
    }


    private static void checkBitmap(Object thiz, Drawable drawable) {
        if (drawable instanceof BitmapDrawable && thiz instanceof View) {
            final Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
            if (bitmap != null) {
                final View view = (View) thiz;
                int width = view.getWidth();
                int height = view.getHeight();
                if (width > 0 && height > 0) {
                    // 二、圖標寬高都大於view的2倍以上,則警告
                    if (bitmap.getWidth() >= (width << 1)
                        &&  bitmap.getHeight() >= (height << 1)) {
                    warn(bitmap.getWidth(), bitmap.getHeight(), width, height, new RuntimeException("Bitmap size too large"));
                }
                } else {
                    // 三、當寬高度等於0時,說明ImageView尚未進行繪製,使用ViewTreeObserver進行大圖檢測的處理。
                    final Throwable stackTrace = new RuntimeException();
                    view.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
                        @Override
                        public boolean onPreDraw() {
                            int w = view.getWidth();
                            int h = view.getHeight();
                            if (w > 0 && h > 0) {
                                if (bitmap.getWidth() >= (w << 1)
                                    && bitmap.getHeight() >= (h << 1)) {
                                    warn(bitmap.getWidth(), bitmap.getHeight(), w, h, stackTrace);
                                }
                                view.getViewTreeObserver().removeOnPreDrawListener(this);
                            }
                            return true;
                        }
                    });
                }
            }
        }
    }

    private static void warn(int bitmapWidth, int bitmapHeight, int viewWidth, int viewHeight, Throwable t) {
        String warnInfo = "Bitmap size too large: " +
            "\n real size: (" + bitmapWidth + ',' + bitmapHeight + ')' +
            "\n desired size: (" + viewWidth + ',' + viewHeight + ')' +
            "\n call stack trace: \n" + Log.getStackTraceString(t) + '\n';

        LogHelper.i(warnInfo);
    }
}
複製代碼

首先,在註釋1處,咱們重寫了 ImageHook 的 afterHookedMethod 方法,拿到了當前的 ImageView 和要設置的 Bitmap 對象。而後,在註釋2處,若是當前 ImageView 的寬高大於0,咱們便進行大圖檢測的處理:ImageView 的寬高都大於 View 的2倍以上,則警告。接着,在註釋3處,若是當前 ImageView 的寬高等於0,則說明 ImageView 尚未進行繪製,則使用 ImageView 的 ViewTreeObserver 獲取其寬高進行大圖檢測的處理。至此,咱們的大圖檢測檢測組件就已經實現了。若是有小夥伴對 epic 的實現原理感興趣的,能夠查看這篇文章

ARTHook方案實現小結
  • 1)、無侵入性
  • 2)、通用性強
  • 3)、兼容性問題大,開源方案不能帶到線上環境

六、線下重複圖片檢測

首先咱們來了解一下這裏的 重複圖片 所指的概念: 即 Bitmap 像素數據徹底一致,可是有多個不一樣的對象存在

重複圖片檢測的原理其實就是 使用內存 Hprof 分析工具,自動將重複 Bitmap 的圖片和引用堆棧輸出

已徹底配置好的項目請參見這裏

使用說明

使用很是簡單,只須要修改 Main 類的 main 方法的第一行代碼,以下所示:

// 設置咱們本身 App 中對應的 hprof 文件路徑
String dumpFilePath = "//Users//quchao//Documents//heapdump//memory-40.hprof";
複製代碼

而後,咱們執行 main 方法便可在 //Users//quchao//Documents//heapdump 這個路徑下看到生成的 images 文件夾,裏面保存了項目中檢測出來的重複的圖片。images 目錄以下所示:

注意:須要使用 8.0 如下的機器,由於 8.0 及之後 Bitmap 中的 buffer 已保存在 native 內存之中。

實現步驟

具體的實現能夠細分爲以下三個步驟:

  • 1)、首先,獲取 android.graphics.Bitmap 實例對象的 mBuffer 做爲 ArrayInstance ,經過 getValues 獲取的數據爲 Object 類型。因爲後面計算 md5 須要爲 byte[] 類型,因此經過反射的方式調用 ArrayInstance#asRawByteArray 直接返回 byte[] 數據
  • 2)、而後,根據 mBuffer 的數據生成 png 圖片文件,這裏直接參考了 github.com/JetBrains/a… 的實現方式。
  • 3)、最後,獲取堆棧信息,直接 使用LeakCanary 獲取 stack 的方法,使用 leakcanary-analyzer-1.6.2.jar 和 leakcanary-watcher-1.6.2.jar 這兩個庫文件。並用 反射 的方式調用了 HeapAnalyzer#findLeakTrace 方法。

其中,獲取堆棧 的信息也能夠直接使用 haha 庫來進行獲取。這裏簡單說一下 使用 haha 庫獲取堆棧的流程,其具體能夠細分爲八個步驟,以下所示:

  • 1)、首先,預備一個已經存在重複 bitmap 的 hprof 文件
  • 2)、利用 haha 庫上的 MemoryMappedFileBuffer 讀取 hrpof 文件 [關鍵代碼 new MemoryMappedFileBuffer(heapDumpFile) ]
  • 3)、解析生成 snapshot,獲取 heap,這裏我只獲取了 app heap [關鍵代碼 snapshot.getHeaps(); heap.getName().equals("app") ]
  • 4)、從 snapshot 中根據指定 class 查找出全部的 Bitmap Classes [關鍵代碼snapshot.findClasses(Bitmap.class.getName()) ]
  • 5)、從 heap 中得到全部的 Bitmap 實例 instance [關鍵代碼 clazz.getHeapInstances(heap.getId()) ]
  • 6)、根據 instance 中獲取全部的屬性信息 Field[],並從 Field[] 查找出咱們須要的 "mWidth" "mHeight" "mBuffer" 信息
  • 7)、經過 "mBuffer" 屬性便可獲取到他們的 hashcode 來判斷是不是重複圖片
  • 8)、最後,經過 instance 中 mNextInstanceToGcRoot 獲取整個引用鏈信息並打印

七、創建全局的線上 Bitmap 監控

爲了創建全局的 Bitmap 監控,咱們必須 對 Bitmap 的分配和回收 進行追蹤。咱們先來看看 Bitmap 有哪些特色:

  • 1)、建立場景比較單一:在 Java 層調用 Bitmap.create 或 BitmapFactory 等方法建立,能夠封裝一層對 Bitmap 建立的接口,注意要 包含調用第三方庫產生的 Bitmap,這裏咱們具體可使用 ASM 編譯插樁 + Gradle Transform 的方式來高效地實現。
  • 2)、建立頻率比較低
  • 3)、和 Java 對象的生命週期同樣服從 GC,可使用 WeakReference 來追蹤 Bitmap 的銷燬

根據以上特色,咱們能夠創建一套 Bitmap 的高性價比監控組件

  • 1)、首先,在接口層將全部建立出來的 Bitmap 放入一個 WeakHashMap 中,並記錄建立 Bitmap 的數據、堆棧等信息。
  • 2)、而後,每隔必定時間查看 WeakHashMap 中有哪些 Bitmap 仍然存活來判斷是否出現 Bitmap 濫用或泄漏。
  • 3)、最後,若是發生了 Bitmap 濫用或泄露,則將相關的數據與堆棧等信息打印出來或上報至 APM 後臺。

這個方案的 性能消耗很低,能夠在 正式環境 中進行。可是,須要注意的一點是,正式與測試環境須要採用不一樣程度的監控。

三、創建線上應用內存監控體系

要創建線上應用的內存監控體系,咱們須要 先獲取 App 的 DalvikHeap 與 NativeHeap,它們的獲取方式可歸結爲以下四個步驟:

  • 一、首先,經過 ActivityManager 的 getProcessMemoryInfo => Debug.MemoryInfo 獲取內存信息數據
  • 二、而後,經過 hook Debug.MemoryInfo 的 getMemoryStat 方法(os v23 及以上)能夠得到 Memory Profiler 中的多項數據,進而得到 細份內存的使用狀況
  • 三、接着,經過 Runtime 獲取 DalvikHeap
  • 四、最後,經過 Debug.getNativeHeapAllocatedSize 獲取 NativeHeap

對於監控場景,咱們須要將其劃分爲兩大類,以下所示:

  • 1)、常規內存監控
  • 2)、低內存監控

一、常規內存監控

根據 斐波那契數列 每隔一段時間(max:30min)獲取內存的使用狀況。常規內存的監控方法有多種實現方式,下面,咱們按照 項目早期 => 壯大期 => 成熟期 的常規內存監控方式進行 演進式 講解。

項目早期:針對場景進行線上 Dump 內存的方式

具體使用 Debug.dumpHprofData() 實現。

其實現的流程爲以下四個步驟:

  • 1)、超過最大內存的 80%
  • 2)、內存 Dump
  • 3)、回傳文件至服務器
  • 4)、MAT 手動分析

可是,這種方式有以下幾個缺點:

  • 1)、Dump文件太大,和對象數正相關,能夠進行裁剪
  • 2)、上傳失敗率高,分析困難

壯大期:LeakCanary帶到線上的方式

在使用 LeakCanary 的時候咱們須要 預設泄漏懷疑點,一旦發現泄漏進行回傳。但這種實現方式缺點比較明顯,以下所示:

  • 1)、不適合全部狀況,須要預設懷疑點
  • 2)、分析比較耗時,容易致使 OOM

成熟期:定製 LeakCanary 方式

那麼,如何定製線上的LeakCanary?

定製 LeakCanary 其實就是對 haha組件 來進行 定製。haha庫是 square 出品的一款 自動分析Android堆棧的java庫。這是haha庫的 連接地址

對於haha庫,它的 基本用法 通常遵循爲以下四個步驟:

一、導出堆棧文件

File heapDumpFile = ...
Debug.dumpHprofData(heapDumpFile.getAbsolutePath());
複製代碼

二、根據堆棧文件建立出內存映射文件緩衝區

DataBuffer buffer = new MemoryMappedFileBuffer(heapDumpFile);
複製代碼

三、根據文件緩存區建立出對應的快照

Snapshot snapshot = Snapshot.createSnapshot(buffer);
複製代碼

四、從快照中獲取指定的類

ClassObj someClass = snapshot.findClass("com.example.SomeClass");
複製代碼

咱們在實現線上版的LeakCanary的時候主要要解決的問題有三個,以下所示:

  • 1)、解決 預設懷疑點 時不許確的問題 => 自動找懷疑點
  • 2)、解決掉將 hprof 文件映射到內存中的時候可能致使內存暴漲甚至發生 OOM 的問題 => 對象裁剪,不所有加載到內存。即對生成的 Hprof 內存快照文件作一些優化:裁剪大部分圖片對應的 byte 數據 以減小文件開銷,最後,使用 7zip 壓縮,通常可 節省 90% 大小
  • 3)、分析泄漏鏈路慢而致使分析時間過長 => 分析 Retain size 大的對象

成熟期:實現內存泄漏監控閉環

在實現了線上版的 LeakCanary 以後,就須要 將線上版的 LeakCanary 與服務器和前端頁面結合 起來。具體的 內存泄漏監控閉環流程 以下所示:

  • 1)、當在線上版 LeakCanary 上發現內存泄漏時,手機將上傳內存快照至服務器
  • 2)、此時服務器分析 Hprof,若是不是系統緣由致使誤報則經過 git 獲得該最近修改人
  • 3)、最後將內存泄漏 bug 單提交給負責人。該負責人經過前端實現的 bug 單系統便可看到本身新增的bug

此外,在實現 圖片內存監控 的過程當中,應注意 兩個關鍵點,以下所示:

  • 1)、在線上能夠按照 不一樣的系統、屏幕分辨率 等緯度去 分析圖片內存的佔用狀況
  • 2)、在 OOM 崩潰時,能夠將 圖片總內存、Top N 圖片佔用內存 寫入 崩潰日誌

二、低內存監控

對於低內存的監控,一般有兩種方式,分別以下所示:

  • 一、利用 onTrimMemory / onLowMemory 監聽系統回調的物理內存警告
  • 二、在後臺起一個服務定時監控系統的內存佔用,只要超過虛擬內存大小最大限制的 90% 則直接觸發內存警告

三、內存監控指標

爲了準確衡量內存性能,咱們須要引入一系列的內存監控指標,以下所示:

1)、發生頻率

2)、發生時各項內存使用情況

3)、發生時App的當前場景

4)、內存異常率

內存 UV 異常率 = PSS 超過 400MB 的 UV / 採集UV
PSS 獲取:調用 Debug.MemoryInfo 的 API 便可
複製代碼

若是出現 新的內存使用不當或內存泄漏 的場景,這個指標會有所 上漲

5)、觸頂率

內存 UV 觸頂率 = Java 堆佔用超過最大堆限制的 85% 的 UV / 採集UV
複製代碼

計算觸頂率的代碼以下所示:

long javaMax = Runtime.maxMemory();
long javaTotal = Runtime.totalMemory();
long javaUsed = javaTotal - runtime.freeMemory();
float proportion = (float) javaUsed / javaMax;
複製代碼

若是超過 85% 最大堆 的限制,GC 會變得更加 頻發,容易形成 OOM 和 卡頓

四、小結

在具體實現的時候,客戶端 儘可能只負責 上報數據,而 指標值的計算 能夠由 後臺 來計算。這樣即可以經過 版本對比監控是否有 新增內存問題。所以,創建線上內存監控的完整方案 至少須要包含如下四點

  • 1)、待機內存、重點模塊內存、OOM率
  • 2)、總體及重點模塊 GC 次數、GC 時間
  • 3)、加強的 LeakCanry 自動化內存泄漏分析
  • 4)、低內存監控模塊的設置

四、創建全局的線程監控組件

每一個線程初始化都須要 mmap 必定的棧大小,在默認狀況下初始化一個線程須要 mmap 1MB 左右的內存空間

32bit 的應用中有 4g 的 vmsize實際能使用的有 3g+,這樣一個進程 最大能建立的線程數 能夠達到 3000個,可是,linux 對每一個進程可建立的線程數也有必定的限制(/proc/pid/limits),而且,不一樣廠商也能修改這個限制,超過該限制就會 OOM。

所以,對線程數量的限制,在必定程度上能夠 有效地避免 OOM 的發生。那麼,實現一套 全局的線程監控組件 即是 刻不容緩 的了。

全局線程監控組件的實現原理

在線下或灰度的環境下經過一個定時器每隔 10分鐘 dump 出應用全部的線程相關信息,當線程數超過當前閾值時,則將當前的線程信息上報並預警

五、GC 監控組件搭建

經過 Debug.startAllocCounting 來監控 GC 狀況,注意有必定 性能影響

Android 6.0 以前 能夠拿到 內存分配次數和大小以及 GC 次數,其對應的代碼以下所示:

long allocCount = Debug.getGlobalAllocCount();
long allocSize = Debug.getGlobalAllocSize();
long gcCount = Debug.getGlobalGcInvocationCount();
複製代碼

而且,在 Android 6.0 及以後 能夠拿到 更精準GC 信息:

Debug.getRuntimeStat("art.gc.gc-count");
Debug.getRuntimeStat("art.gc.gc-time");
Debug.getRuntimeStat("art.gc.blocking-gc-count");
Debug.getRuntimeStat("art.gc.blocking-gc-time");
複製代碼

對於 GC 信息的排查,咱們通常關注 阻塞式GC的次數和耗時,由於它會 暫停線程,可能致使應用發生 卡頓。建議 僅對重度場景使用

六、創建線上 OOM 監控組件:Probe

美團的 Android 內存泄漏自動化鏈路分析組件 ProbeOOM 時會生成 Hprof 內存快照,而後,它會經過 單獨進程 對這個 文件 作進一步 分析

Probe 組件的缺陷及解決方案

它的缺點比較多,具體爲以下幾點:

  • 一、在崩潰的時候生成內存快照容易致使二次崩潰
  • 二、部分手機生成 Hprof 快照比較耗時
  • 三、部分 OOM 是由虛擬內存不足致使

在實現自動化鏈路分析組件 Probe 的過程當中主要要解決兩個問題,以下所示:

一、鏈路分析時間過長

  • 1)、使用鏈路歸併:將具備 相同層級與結構 的鏈路進行 合併
  • 2)、使用 自適應擴容法經過不斷比較現有鏈路和新鏈路,結合擴容因子,逐漸完善爲完整的泄漏鏈路

二、分析進程佔用內存過大

分析進程佔用的內存內存快照文件的大小 不成正相關,而跟 內存快照文件的 Instance 數量正相關。因此在開發過程當中咱們應該 儘量排除不須要的Instance實例

Prope 分析流程揭祕

Prope 的 整體架構圖 以下所示:

image

而它的整個分析流程具體能夠細分爲八個步驟,以下所示:

一、hprof 映射到內存 => 解析成 Snapshot & 計數壓縮:

解析後的 Snapshot 中的 Heap 有四種類型,具體爲:

  • 1)、DefaultHeap
  • 2)、ImageHeap
  • 3)、App Heap:包括 ClassInstance、ClassObj、ArrayInstance、RootObj
  • 4)、System Heap

解析完 後使用了 計數壓縮策略,對 相同的 Instance 使用 計數,以 減小佔用內存。超過計數閾值的須要計入計數桶(計數桶記錄了 丟棄個數 和 每一個 Instance 的大小)

二、生成 Dominator Tree

三、計算 RetainSize

四、生成 Reference 鏈 && 基礎數據類型加強:

若是對象是 基礎數據類型,會將 自身的 RetainSize 累加到父節點 上,將 懷疑對象 替換爲它的 父節點

五、鏈路歸併

六、計數桶補償 & 基礎數據類型和父節點融合

使用計數補償策略計算 RetainSize,主要是 判斷對象是否在計數桶中,若是在的話則將 丟棄的個數和大小補償到對象上,累積計算RetainSize,最後對 RetainSize 排序以查找可疑對象

七、排序擴容

八、查找泄露鏈路

七、實現 單機版 的 Profile Memory 自動化內存分析

項目地址請點擊此處

在配置的時候要注意兩個問題:

  • 一、liballoc-lib.so在構建後工程的 build => intermediates => cmake 目錄下。將對應的 cpu abi 目錄拷貝到新建的 libs 目錄下

  • 二、在 DumpPrinter Java 庫的 build.gradle 中的 jar 閉包中須要加入如下代碼以識別源碼路徑:

    sourceSets.main.java.srcDirs = ['src']

使用步驟

具體的使用步驟以下所示:

一、首先,點擊 」開始記錄「 按鈕能夠看到觸發對象分配的記錄,說明對象已經開始記錄對象的分配,log以下所示:

12-26 10:54:03.963 30450-30450/com.dodola.alloctrack I/AllocTracker: ====current alloc count 388=====
複製代碼

二、而後,點擊屢次 」生成1000個對象「 按鈕,當對象達到設置的最大數量的時候觸發內存dump,會獲得保存數據路徑的日誌。以下所示:

12-26 10:54:03.963 30450-30450/com.dodola.alloctrack I/AllocTracker: ====current alloc count 388=====
12-26 10:56:45.103 30450-30450/com.dodola.alloctrack I/AllocTracker: saveARTAllocationData write file to /storage/emulated/0/crashDump/1577329005
複製代碼

三、此時,能夠看到數據保存在 sdk 下的 crashDump 目錄下。

四、接着,經過 gradle task :buildAlloctracker 任務編譯出存放在 tools/DumpPrinter-1.0.jar 的 dump 工具,而後採用以下命令來將數據解析 到dump_log.txt 文件中。

java -jar tools/DumpPrinter-1.0.jar dump文件路徑 > dump_log.txt
複製代碼

五、最後,就能夠在 dump_log.txt 文件中看到解析出來的數據,以下所示:

Found 4949 records:
tid=1 byte[] (94208 bytes)
    dalvik.system.VMRuntime.newNonMovableArray (Native method)
    android.graphics.Bitmap.nativeCreate (Native method)
    android.graphics.Bitmap.createBitmap (Bitmap.java:975)
    android.graphics.Bitmap.createBitmap (Bitmap.java:946)
    android.graphics.Bitmap.createBitmap (Bitmap.java:913)
    android.graphics.drawable.RippleDrawable.updateMaskShaderIfNeeded (RippleDrawable.java:776)
    android.graphics.drawable.RippleDrawable.drawBackgroundAndRipples (RippleDrawable.java:860)
    android.graphics.drawable.RippleDrawable.draw (RippleDrawable.java:700)
    android.view.View.getDrawableRenderNode (View.java:17736)
    android.view.View.drawBackground (View.java:17660)
    android.view.View.draw (View.java:17467)
    android.view.View.updateDisplayListIfDirty (View.java:16469)
    android.view.ViewGroup.recreateChildDisplayList (ViewGroup.java:3905)
    android.view.ViewGroup.dispatchGetDisplayList (ViewGroup.java:3885)
    android.view.View.updateDisplayListIfDirty (View.java:16429)
    android.view.ViewGroup.recreateChildDisplayList (ViewGroup.java:3905)
複製代碼

八、搭建線下 Native 內存泄漏監控體系

Android 8.0 及以後,可使用 Address Sanitizer、Malloc 調試和 Malloc 鉤子 進行 native 內存分析,參見 native_memory

對於線下 Native 內存泄漏監控的創建,主要針對 是否能重編 so 的狀況 來記錄分配的內存信息。

針對沒法重編so的狀況

  • 1)、首先,使用 PLT Hook 攔截庫的內存分配函數,而後,重定向到咱們本身的實現後去 記錄分配的 內存地址、大小、來源so庫路徑 等信息。
  • 2)、最後,按期 掃描分配與釋放 的配對內存塊,對於 不配對的分配 輸出上述記錄的信息

針對可重編的so狀況

  • 1)、首先,經過 GCC」-finstrument-functions「 參數給 全部函數插樁,而後,在樁中模擬調用棧的入棧與出棧操做
  • 2)、接着,經過 ld」--warp「 參數 攔截內存分配和釋放函數,重定向到咱們本身的實現後記錄分配的 內存地址、大小、來源so以及插樁調用棧此刻的內容
  • 3)、最後,按期掃描分配與釋放是否配對,對於不配對的分配輸出咱們記錄的信息

九、設置內存兜底策略

設置內存兜底策略的目的,是爲了 在用戶無感知的狀況下,在接近觸發系統異常前,選擇合適的場景殺死進程並將其重啓,從而使得應用內存佔用回到正常狀況

一般執行內存兜底策略時至少須要知足六個條件,以下所示:

  • 1)、是否在主界面退到後臺且位於後臺時間超過 30min
  • 2)、當前時間爲早上 2~5 點
  • 3)、不存在前臺服務(通知欄、音樂播放欄等狀況)
  • 4)、Java heap 必須大於當前進程最大可分配的85% || native內存大於800MB
  • 5)、vmsize 超過了4G(32bit)的85%
  • 6)、非大量的流量消耗(不超過1M/min) && 進程無大量CPU調度狀況

只有在知足了以上條件以後,咱們纔會去殺死當前主進程並經過 push 進程從新拉起及初始化

十、更深刻的內存優化策略

除了在 Android性能優化以內存優化 => 優化內存空間 中講解過的一些常規的內存優化策略之外,在下面列舉了一些更深刻的內存優化策略。

一、使 bitmap 資源在 native 中分配

對於 Android 2.x 系統,使用反射將 BitmapFactory.Options 裏面隱藏的 inNativeAlloc 打開

對於 Android 4.x 系統,使用或借鑑 Fresco 將 bitmap 資源在 native 中分配的方式

二、圖片加載時的降級處理

使用 Glide、Fresco 等圖片加載庫,經過定製,在加載 bitmap 時,若發生 OOM,則使用 try catch 將其捕獲,而後清除圖片 cache,嘗試下降 bitmap format(ARGB888八、RGB56五、ARGB444四、ALPHA8)。

須要注意的是,OOM 是能夠捕獲的,只要 OOM 是由 try 語句中的對象聲明所致使的,那麼在 catch 語句中,是能夠釋放掉這些對象,解決 OOM 的問題的。

三、前臺每隔 3 分鐘去獲取當前應用內存佔最大內存的比例,超過設定的危險閾值(如80%)則主動釋放應用 cache(Bitmap 爲大頭),而且顯示地除去應用的 memory,以加速內存收集的過程。

計算當前應用內存佔最大內存的比例的代碼以下:

max = Runtime.getRuntime().maxMemory();
available = Runtime.getRuntime.totalMemory() - Runtime.getFreeMemory();
ratio = available / max;
複製代碼

顯示地除去應用的 memory,以加速內存收集過程的代碼以下所示:

WindowManagerGlobal.getInstance().startTrimMemory(TRIM_MEMORY_COMPLETE);
複製代碼

四、因爲 webview 存在內存系統泄漏,還有 圖庫佔用內存過多 的問題,能夠採用單獨的進程。

五、當UI隱藏時釋放內存

當用戶切換到其它應用而且你的應用 UI 再也不可見時,應該釋放應用 UI 所佔用的全部內存資源。這可以顯著增長系統緩存進程的能力,可以提高用戶體驗。

在全部 UI 組件都隱藏的時候會接收到 Activity 的 onTrimMemory() 回調並帶有參數 TRIM_MEMORY_UI_HIDDEN

六、Activity 的兜底內存回收策略

在 Activity 的 onDestory 中遞歸釋放其引用到的 Bitmap、DrawingCache 等資源,以下降發生內存泄漏時對應用內存的壓力。

七、使用相似 Hack 的方式修復系統內存泄漏

LeakCanary 的 AndroidExcludeRefs 列出了一些因爲系統緣由致使引用沒法釋放的例子,可以使用相似 Hack 的方式去修復。具體的實現代碼能夠參考 Booster => 系統問題修復

八、當應用使用的Service再也不使用時應該銷燬它,建議使用 IntentServcie。

九、謹慎使用第三方庫,避免爲了使用其中一兩個功能而導入一個大而全的解決方案。

深刻探索 Android 內存優化(煉獄級別-下)

參考連接:

一、國內Top團隊大牛帶你玩轉Android性能分析與優化 第四章 內存優化

二、極客時間之Android開發高手課 內存優化

三、微信 Android 終端內存優化實踐

四、GMTC-Android內存泄漏自動化鏈路分析組件Probe.key

五、Manage your app's memory

六、Overview of memory management

七、Android內存優化雜談

八、Android性能優化以內存篇

九、管理應用的內存

十、《Android移動性能實戰》第二章 內存

十一、天天一個linux命令(44):top命令

十二、Android內存分析命令

很感謝您閱讀這篇文章,但願您能將它分享給您的朋友或技術羣,這對我意義重大。

但願咱們能成爲朋友,在 Github掘金上一塊兒分享知識。

相關文章
相關標籤/搜索