反思 系列博客是個人一種新學習方式的嘗試,該系列起源和目錄請參考 這裏 。html
對於Android
開發者而言,ANR
是一個老生常談的問題,站在面試者的角度,彷佛說出 「不要在主線程作耗時操做」 就算合格了。java
可是,ANR
機制究竟是什麼,其背後的原理究竟如何,爲何要設計出這樣的機制?這些問題時時刻刻會縈繞腦海,而想搞清楚這些,就不得不提到Android
自身的 輸入系統 (Input System
)。android
Android
自身的 輸入系統 又是什麼?一言以蔽之,任何與Android
設備的交互——咱們稱之爲 輸入事件,都須要經過 輸入系統 進行管理和分發;這其中最靠近上層,而且最典型的一個小環節就是View
的 事件分發 流程。git
這樣看來,輸入系統 自己確實是一個很是龐大複雜的命題,而且,越靠近底層細節,越容易有一種 只見樹木不見樹林 之感,反覆幾回,直至迷失在細節代碼的較真中,一次學習的努力嘗試付諸東流。github
所以,控制住原理分析的粒度,在宏觀的角度,系統地瞭解輸入系統自己的設計理念,並引伸到實際開發中的ANR
現象的原理和解決思路 ,是一個很是不錯的理論與實踐相結合的學習方式,這也正是筆者寫做本文的初衷。面試
本文篇幅較長,思惟導圖以下:安全
談到Android
系統自己,首先,必須將 應用進程 和 系統進程 有一個清晰的認知,前者通常表明開發者依託Android
平臺自己創造開發的應用;後者則表明 Android
系統自身建立的核心進程。markdown
這裏咱們拋開 應用進程 ,先將視線轉向 系統進程,由於 輸入系統 自己是由後者初始化和管理調度的。數據結構
Android
系統在啓動的時候,會初始化zygote
進程和由zygote
進程fork
出來的SystemServer
進程;做爲 系統進程 之一,SystemServer
進程會提供一系列的系統服務,而接下來要講到的InputManagerService
也正是由 SystemServer
提供的。多線程
在SystemServer
的初始化過程當中,InputManagerService
(下稱IMS
)和WindowManagerService
(下稱WMS
)被建立出來;其中WMS
自己的建立依賴IMS
對象的注入:
// SystemServer.java
private void startOtherServices() {
// ...
InputManagerService inputManager = new InputManagerService(context);
// inputManager做爲WindowManagerService的構造參數
WindowManagerService wm = WindowManagerService.main(context,inputManager, ...);
}
複製代碼
在 輸入系統 中,WMS
很是重要,其負責管理IMS
、Window
與ActivityManager
之間的通訊,這裏點到爲止,後文再進行補充,咱們先來看IMS
。
顧名思義,IMS
服務的做用就是負責輸入模塊在Java
層級的初始化,並經過JNI
調用,在Native
層進行更下層輸入子系統相關功能的建立和預處理。
在JNI
的調用過程當中,IMS
建立了NativeInputManager
實例,NativeInputManager
則在初始化流程中又建立了EventHub
和InputManager
:
NativeInputManager::NativeInputManager(jobject contextObj, jobject serviceObj, const sp<Looper>& looper) : mLooper(looper), mInteractive(true) {
// ...
// 建立一個EventHub對象
sp<EventHub> eventHub = new EventHub();
// 建立一個InputManager對象
mInputManager = new InputManager(eventHub, this, this);
}
複製代碼
此時咱們已經處於Native
層級。讀者須要注意,對於整個Native
層級而言,其向下負責與Linux
的設備節點中獲取輸入,向上則與靠近用戶的Java
層級相通訊,能夠說是很是重要。而在該層級中,EventHub
和InputManager
又是最核心的兩個角色。
這兩個角色的職責又是什麼呢?首先來講EventHub
,它是底層 輸入子系統 中的核心類,負責從物理輸入設備中不斷讀取事件(Event
),而後交給InputManager
,後者內部封裝了InputReader
和InputDispatcher
,用來從EventHub
中讀取事件和分發事件:
InputManager::InputManager(...) {
mDispatcher = new InputDispatcher(dispatcherPolicy);
mReader = new InputReader(eventHub, readerPolicy, mDispatcher);
initialize();
}
複製代碼
簡單來看,EventHub
創建了Linux
與輸入設備之間的通訊,InputManager
中的InputReader
和InputDispatcher
負責了輸入事件的讀取和分發,在 輸入系統 中,二者的確很是重要。
這裏借用網上的圖對此進行一個簡單的歸納:
對於EventHub
的具體實現,絕大多數App
開發者也許並不須要去花太多時間深刻——簡單瞭解其職責,而後一筆帶過彷佛是筆劃算的買賣。
可是在EventHub
的實現細節中筆者發現,其對epoll
機制的利用是一個很是經典的學習案例,所以,花時間稍微深刻了解也絕對是一箭雙鵰。
上文說到,EventHub
創建了Linux
與輸入設備之間的通訊,其實這種描述是不許確的,那麼,EventHub
是爲了解決什麼問題而設計的呢,其具體又是如何實現的?
咱們知道,Android
設備能夠同時鏈接多個輸入設備,好比 屏幕 、 鍵盤 、 鼠標 等等,用戶在任意設備上的輸入都會產生一箇中斷,經由Linux
內核的中斷處理及設備驅動轉換成一個Event
,最終交給用戶空間的應用程序進行處理。
Linux
內核提供了一個便於將不一樣設備不一樣數據接口統一轉換的抽象層,只要底層輸入設備驅動程序按照這層抽象接口實現,應用就能夠經過統一接口訪問全部輸入設備,這即是Linux
內核的 輸入子系統。
那麼 輸入子系統 如何是針對接收到的Event
進行的處理呢?這就不得不提到EventHub
了,它是底層Event
處理的樞紐,其利用了epoll
機制,不斷接收到輸入事件Event
,而後將其向上層的InputReader
傳遞。
這是常見於面試Handler
相關知識點時的一道進階題,變種問法是:「既然Handler
中的Looper
中經過一個死循環不斷輪詢,爲何程序沒有由於無限死循環致使崩潰或者ANR
?」
讀者應該知道,Handler
簡單的利用了epoll
機制,作到了消息隊列的阻塞和喚醒。關於epoll
機制,這裏有一篇很是經典的解釋,不瞭解其設計理念的讀者 有必要 瞭解一下:
參考上文,這裏咱們對epoll
機制進行一個簡單的總結:
epoll
能夠理解爲event poll
,不一樣於忙輪詢和無差異輪詢,在 多個輸入流 的狀況下,epoll
只會把哪一個流發生了怎樣的I/O事件通知咱們。此時咱們對這些流的操做都是有意義的。
EventHub
中使用epoll
的恰到好處——多個物理輸入設備對應了多個不一樣的輸入流,經過epoll
機制,在EventHub
初始化時,分別建立mEpollFd
和mINotifyFd
;前者用於監聽設備節點是否有設備文件的增刪,後者用於監聽是否有可讀事件,建立管道,讓InputReader
來讀取事件:
本章節將對InputReader
和InputDispatcher
進行系統性的介紹。
InputReader
是什麼?簡單理解InputReader
的做用,經過從EventHub
獲取事件後,將事件進行對應的處理,而後將事件進行封裝並添加到InputDispatcher
的隊列中,最後喚醒InputDispatcher
進行下一步的事件分發。
乍得一看,在 輸入系統 的Native
層中,InputReader
彷佛平凡無奇,但越是看似樸實無華的事物,在整個流程中每每佔據絕對重要的做用。
首先,EventHub
傳過來的Event
除了普通的 輸入事件 外,還包含了設備自己的增、刪、掃描 等事件,這些額外的事件處理並無直接交給InputDispatcher
去分發,而是在InputReader
中進行了處理。
當某個時間發生——多是用戶 按鍵輸入,或者某個 設備插入,亦或 設備屬性被調整 ,epoll_wait()
返回並將Event
存入。
這以後,InputReader
對輸入事件進行了一次讀取,由於不一樣設備對事件的處理邏輯又各自不一樣,所以InputReader
內部持有一系列的Mapper
對事件進行 匹配 ,若是不匹配則忽略事件,反之則將Event
封裝成一個新的NotifyArgs
數據對象,準備存入隊列中,即喚醒InputDispatcher
進行分發。
巧妙的是,在喚醒InputDispatcher
進行分發以前,InputReader
在本身的線程中先執行了一個很特殊的 攔截操做 環節。
讀者知道,在應用開發中,一些特殊的輸入事件是沒法經過普通的方式進行攔截的;好比音量鍵,Power
鍵,電話鍵,以及一些特殊的組合鍵,這裏咱們通稱爲 系統按鍵。
這點無可厚非,雖然Android
系統對於開發者足夠的開放,可是一切都是有限制的,絕大多數的 用戶按鍵 一般能夠被應用攔截處理,可是 系統按鍵 絕對不行——這種限制每每可以給予用戶設備安全最後的保障。
所以,在InputReader
喚醒InputDispatcher
進行事件分發以前,InputReader
在本身的線程中進行了兩輪攔截處理。
首先的第一輪攔截操做就是對 系統按鍵 級別的 輸入事件 進行處理,對於手機而言,這個工做是在PhoneWindowManager
中完成;舉例來講,當用戶按了Power
(電源)鍵,Android
設備自己會切喚醒或睡眠——即亮屏和息屏。
這也正是「在技術論壇中,一般對 系統按鍵 攔截處理的技術方案,基本都是須要修改PhoneWindowManager
的源碼」的緣由。
接下來輸入事件進入到第二輪的處理中,若是用戶在Setting->Accessibility
中選擇打開某些功能,以 手勢識別 爲例,Android
的AccessbilityManagerService
(輔助功能服務) 可能會根據須要轉換成新的Event
,好比說兩根手指頭捏動的手勢最終會變成ZoomEvent
。
須要注意的是,這裏的攔截處理並不會真正將事件 消費 掉,而是經過特殊的方式將事件進行標記(policyFlags
),而後在InputDispatcher
中處理。
至此,InputReader
對 輸入事件 完整的一輪處理到此結束,這以後,InputReader
又進入了新一輪等待。
當wake()
函數將在Looper
中睡眠等待的InputDispatcher
喚醒時,InputDispatcher
開始新一輪事件的分發。
準確來講,
InputDispatcher
被喚醒時,wake()
函數實際是在InputManagerService
的線程中執行的,即整個流程的線程切換順序爲InputReaderThread
->InputManagerServiceThread
->InputDispatcherThread
。
InputDispatcher
的線程負責將接收到的 輸入事件 分發給 目標應用窗口,在這個過程當中,InputDispatcher
首先須要對上個環節中標記了須要攔截的 系統按鍵 相關事件進行攔截,被攔截的事件至此再也不向下分發。
這以後,InputDispatcher
進入了本文最關鍵的一個環節——調用 findFocusedWindowTargetLocked()
獲取當前的 焦點窗口 ,同時檢測目標應用是否有ANR
發生。
若是檢測到目標窗口處於正常狀態,即ANR
並未發生時,InputDispatcher
進入真正的分發程序,將事件對象進行新一輪的封裝,經過SocketPair
喚醒目標窗口所在進程的Looper
線程,即咱們應用進程中的主線程,後者會讀取相應的鍵值並進行處理。
表面來看,整個分發流程彷佛乾淨簡潔且便於理解,但實際上InputDispatcher
整個流程的邏輯十分複雜,試想一次事件分發要橫跨3個線程的流程又怎會簡單?
此外,InputDispatcher
還負責了 ANR 的處理,這又致使整個流程的複雜度又上升了一個層級,這個流程咱們在後文的ANR
章節中進行更細緻的分析,所以先按住不提。
接下來,咱們來看看整個 輸入事件 的分發流程中, 應用進程 是如何與 系統進程 創建相應的通訊連接的。
關於 跨進程通訊的創建 這一節,筆者最初打算做爲一個大的章節來說,可是對於整個 輸入系統 而言,其彷佛又只是一個 重要非必需 的知識點。最終,筆者將其放在一個小節中進行簡單的描述,有興趣的讀者能夠在文末的參考連接中查閱更詳盡的資料。
咱們知道,InputReader
和InputDispatcher
運行在system_server
系統進程 中,而用戶操做的應用都運行在本身的 應用進程 中;這裏就涉及到跨進程通訊,那麼 應用進程 是如何與 系統進程 創建通訊的呢?
讓咱們回到文章最初WindowManagerService(WMS)
和InputManagerService(IMS)
初始化的流程中來,當IMS
以及其餘的系統服務初始化完成以後,應用程序開始啓動。
若是一個應用程序有Activity
(只有Activity
可以接受用戶輸入),那麼它要將本身的Window
註冊到WMS
中。
在這裏,Android
使用了Socket
而不是Binder
來完成。WMS
中經過OpenInputChannelPair
生成了兩個Socket
的FD
, 表明一個雙向通道的兩端:向一端寫入數據,另一端即可以讀出;反之,若是一端沒有寫入數據,另一端去讀,則陷入阻塞等待。
最終InputDispatcher
中創建了目標應用的Connection
對象,表明與遠端應用的窗口創建了連接;一樣,應用進程中的ViewRootImpl
建立了WindowInputEventReceiver
用於接受InputDispatchor
傳過來的事件:
這裏咱們對該次 跨進程通訊創建流程 有了初步的認知,對於Android
系統而言,Binder
是最普遍的跨進程通訊的應用方式,可是Android
系中跨進程通訊就僅僅只用到了Binder
嗎?答案是否認的,至少在 輸入系統 中,除了Binder
以外,Socket
一樣起到了舉足輕重的做用。
那麼新的問題就來了,這裏爲何選擇Socket
而不是選擇Binder
呢,關於這個問題的解釋,筆者找到了一個很好的版本:
Socket
能夠實現異步的通知,且只須要兩個線程參與(Pipe
兩端各一個),假設系統有N
個應用程序,跟輸入處理相關的線程數目是N+1
(1
是Input Dispatcher
線程)。然而,若是用Binder
實現的話,爲了實現異步接收,每一個應用程序須要兩個線程,一個Binder
線程,一個後臺處理線程(不能在Binder
線程裏處理輸入,由於這樣太耗時,將會堵塞住發送端的調用線程)。在發送端,一樣須要兩個線程,一個發送線程,一個接收線程來接收應用的完成通知,因此,N
個應用程序須要2(N+1)
個線程。相比之下,Socket
仍是高效多了。
如今,應用進程 可以收到由InputDispatcher
處理完成並分發過來的 輸入事件 了。至此,咱們來到了最熟悉的應用層級事件分發流程。對於這以後 應用層級的事件分發,能夠閱讀下述筆者的另外兩篇文章,本文不贅述。
對 輸入系統 有了更初步總體的認知以後,接下來本文將針對ANR
機制進行更深一步的探索。
一般來說,ANR
的來源分爲Service、Broadcast、Provider
以及Input
兩種。
這樣區分的緣由是,首先,前者發生在 應用進程 組件中的ANR
問題一般是相對好解決的,若ANR
自己容易復現,開發者一般僅須要肯定組件的代碼中是否在 主線程中作了耗時處理;然後者ANR
發生的緣由爲 輸入事件 分發超時,包括按鍵和屏幕的觸摸事件,經過閱讀上一章節,讀者知道 輸入系統 中負責處理ANR
問題的是處於 系統進程 中的InputDispatcher
,其整個流程相比前者而言邏輯更加複雜。
簡單理解了以後,讀者須要知道,「組件類ANR
發生緣由一般是因爲 主線程中作了耗時處理」這種說法其實是籠統的,更準確的講,其本質的緣由是 組件任務調度超時,而在設備資源緊湊的狀況下,ANR
的發生更可能是綜合性的緣由。
而Input
類型的ANR
相對於Service、Broadcast、Provider
,其內部的機制又大相徑庭。
具體不一樣在哪裏呢,對於Service、Broadcast、Provider
組件類的ANR
而言,Gityuan 在 這篇文章 中作了一個很是精妙的解釋:
ANR
是一套監控Android
應用響應是否及時的機制,能夠把發生ANR
比做是 引爆炸彈,那麼整個流程包含三部分組成:
- 埋定時炸彈:中控系統(
system_server
進程)啓動倒計時,在規定時間內若是目標(應用進程)沒有幹完全部的活,則中控系統會定向炸燬(殺進程)目標。- 拆炸彈:在規定的時間內幹完工地的全部活,並及時向中控系統報告完成,請求解除定時炸彈,則倖免於難。
- 引爆炸彈:中控系統當即封裝現場,抓取快照,蒐集目標執行慢的罪證(
traces
),便於後續的案件偵破(調試分析),最後是炸燬目標。
將組件的ANR
機制比喻爲 定時炸彈 很是貼切,以Service
爲例,對於Android
系統而言,啓動一個服務其本質是進程間的異步通訊,那麼,如何判斷Service
是否啓動成功,若是一直沒有成功,那麼如何處理?
所以Android
設計了一個 置之死地然後生 的機制,在嘗試啓動Service
時,讓中控系統system_server
埋下一個 定時炸彈 ,當Service
完成啓動,拆掉炸彈;不然在system_server
的ActivityManager
線程中引爆炸彈,這就是組件類ANR
機制的原理:
接下來簡單瞭解一下 輸入系統 流程中ANR
機制的原理。
Input
類型的ANR
在平常開發中更爲常見且更復雜,好比用戶或者測試反饋,點擊屏幕中的UI元素致使「卡死」。
少數狀況下開發者可以很快定位到問題,但更常見的狀況是,該問題是 隨機 且 難以復現 的,致使該問題的緣由也更具備綜合性,好比低端設備的系統自己資源已很是緊張,或者多線程相互持有彼此須要的資源致使 死鎖 ,亦或其它複雜的狀況,所以處理這類型問題就須要開發者對 輸入系統 中的ANR
機制有必定的瞭解。
與組件類ANR
不一樣的是,Input
類型的超時機制並不是時間到了必定就會爆炸,而是處理後續上報事件的過程纔會去檢測是否該爆炸,因此更像是 掃雷 的過程。
什麼叫作 掃雷 呢,對於 輸入系統 而言,即便某次事件執行時間超過預期的時長,只要用戶後續沒有再生成輸入事件,那麼也不須要ANR
。
而只有當新一輪的輸入事件到來,此時正在分發事件的窗口(即App
應用自己)遲遲沒法釋放資源給新的事件去分發,這時InputDispatcher
纔會根據超時時間,動態的判斷是否須要向對應的窗口提示ANR
信息。
這也正是用戶在第一次點擊屏幕,即便事件處理超時,也沒有彈出ANR
窗口,而當用戶下意識再次點擊屏幕時,屏幕上才提示出了ANR
信息的緣由。
因而可知,組件類ANR
和Input ANR
原理上確實有所不一樣;除此以外,前者是在ActivityManager
線程中處理的ANR
信息,後者則是在InputDispatcher
線程中處理的ANR
,這裏經過一張圖簡單瞭解一下後者的總體流程:
如今咱們對Input
類型的ANR
機制有了一個簡單的瞭解,下文將針對其更深刻性的細節實現進行探討。
咱們再次將目光轉回到InputDispatcher
的實現細節。
先拋出一個新的問題,對處於system_server
進程Native
層級的 事件分發 而言,其向下與 應用進程 的通訊的過程應該是同步仍是異步的?
對於讀者而言,不可貴出答案是異步的,由於二者之間雙向通訊的創建是經過SocketPair
,而且,由於system_server
中InputDispatcher
對事件的分發其實是一對多的,若是是同步的,那麼一旦其中一個應用分發超時,那麼InputDispatcher
線程天然被卡住,其永遠都不可能進入到下一輪的事件分發中,掃雷 機制更是無從談起。
所以,與應用進程中事件分發不一樣的是,後者咱們一般能夠認爲是在主線程中同步的,而對於整個 輸入系統 而言,由於涉及到 系統進程 與多個 應用進程 之間異步的通訊,所以其內部的實現更爲複雜。
由於事件分發涉及到異步回調機制,所以InputDispatcher
須要對事件進行維護和管理,那麼問題就變成了,使用什麼樣的數據結構去維護這些輸入事件比較合適。
InputDispatcher
的源碼實現中,總體的事件分發流程共使用到3個事件隊列:
InputReader
發送過來的輸入事件;下文,筆者經過2輪事件分發的示例,對三個隊列的做用進行簡單的梳理。
首先InputReader
線程經過EventHub
監聽到底層的輸入事件上報,並將其放入了mInBoundQueue
中,同時喚醒了InputDispatcher
線程。
而後InputDispatcher
開始了第一輪的事件分發,此時並無正在處理的事件,所以InputDispatcher
從mInBoundQueue
隊列頭部取出事件,並重置ANR
的計時,並檢查窗口是否就緒,此時窗口準備就緒,將該事件轉移到了outBoundQueue
隊列中,由於應用管道對端鏈接正常,所以事件從outBoundQueue
取出,而後放入了waitQueue
隊列,由於Socket
雙向通訊已經創建,接下來就是 應用進程 接收到新的事件,而後對其進行分發。
若是 應用進程 事件分發正常,那麼會經過Socket
向system_server
通知完成,則對應的事件最終會從waitQueue
隊列中移除。
若是第一輪事件分發還沒有接收到回調通知,第二輪事件分發抵達又是如何處理的呢?
第二輪事件到達InputDispatcher
時,此時InputDispatcher
發現有事件正在處理,所以不會從mInBoundQueue
取出新的事件,而是直接檢查窗口是否就緒,若未就緒,則進入ANR
檢測狀態。
如下幾種狀況會致使進入ANR
檢測狀態:
一、目標應用不會空,而目標窗口爲空。說明應用程序在啓動過程當中出現了問題; 二、目標
Activity
的狀態是Pause
,即再也不是Focused
的應用; 三、目標窗口還在處理上一個事件。
讀者須要理解,並不是全部「目標窗口還在處理上一個事件」都會拋出ANR
,而是須要經過檢測時間,若是未超時,那麼直接停止本輪事件分發,反之,若是事件分發超時,那麼纔會肯定ANR
的發生。
這也正是將Input
類型的ANR
描述爲 掃雷 的緣由:這裏的掃雷是指當前輸入系統中正在處理着某個耗時事件的前提下,後續的每一次input
事件都會檢測前一個正在處理的事件是否超時(進入掃雷狀態),檢測當前的時間距離上次輸入事件分發時間點是否超時。若是前一個輸入事件,則會重置ANR
的timeout
,從而不會爆炸。
至此,輸入系統 檢測到了ANR
的發生,並向上層拋出了本次ANR
的相關信息。
本文旨在對Android
輸入系統 進行一個系統性的概述,讀者不該將本文做爲惟一的學習資料,而應該經過本文對該知識體系進行初步的瞭解,並根據自身要求進行單個方向細節性的突破。而已經掌握了骨骼架構的讀者而言,更細節性的知識點也不過是待豐富的血肉而已。
本文從立題至發佈,整個流程耗時近1個半月,在這個過程當中,筆者參考了較本文內容數十倍的資料,受益頗深,也深感以 舉重若輕 爲寫文目標之艱難——內容鋪展容易,但經過 簡潔 且 連貫 的語言來對一個龐大複雜的知識體系進行收攏,須要極強的 剋制力 ,在這種嚴苛的要求下,每一句的描述都須要極高的 精確性 ,這對筆者而言是一個挑戰,但真正完成以後,對整個知識體系的理解程度一樣也是極高的。
而這也正是 反思 系列的初衷,但願你能喜歡。
正如上文所言,輸入系統 和 ANR 自己都是一個很是大的命題,除了寬廣的知識體系,還須要親身去實踐和總結,下文列出若干相關參考資料,讀者可根據自身需求選擇性進行擴展閱讀:
一、完全理解安卓應用無響應機制 @Gityuan
二、Input系統—ANR原理分析 @Gityuan
三、理解Android ANR的觸發原理 @Gityuan
深刻學習ANR
機制資料,Gityuan
的ANR
博客系列絕對是先驅級別的,尤爲是第1篇文章中,其對於 定時炸彈 和 掃雷 的形容,貼切且易理解,這種 舉重若輕 的寫做風格體現了做者自己對整個知識體系的深度掌握;然後兩篇文章則針對兩種類型的ANR
分別進行了源碼級別的分析,很是下飯。
四、圖解Android-Android的 Event Input System @漫天塵沙
筆者曾經想寫一個 圖解Android 系列,後來由於種種緣由放棄了,沒想到若干年前已經有先驅進行過了這樣的嘗試,而且,內容質量極高。筆者相信,可以花費很是大精力總結的文章必定不會被埋沒,而這篇文章,註定會成爲經典中的經典。
一個筆者最近關注很是優秀的做者,文章很是具備深度,其Input
系列針對整個輸入系統進行了更細緻源碼級別的分析,很是值得收藏。
六、Android 信號處理面面觀 之 信號定義、行爲和來源 @rambo2188
若是讀者對「Android
系統信號處理的行爲」感興趣,那麼這篇文章絕對不能錯過。
七、Android開發高手課 @張紹文
實戰中的經典之做,該課程每一小結都極具深度,價值不可估量。因或涉及到利益相關,並且推薦了也從張老師那裏拿不到錢,所以本文不加連接並放在最下面(笑)。
Hello,我是 卻把清梅嗅 ,若是您以爲文章對您有價值,歡迎 ❤️,也歡迎關注個人 博客 或者 GitHub。
若是您以爲文章還差了那麼點東西,也請經過 關注 督促我寫出更好的文章——萬一哪天我進步了呢?