Android動態加載技術 簡單易懂的介紹方式

原文鏈接:https://segmentfault.com/a/1190000004062866

我們很早開始就在Android項目中採用了動態加載技術,主要目的是爲了達到讓用戶不用重新安裝APK就能升級應用的功能(特別是 SDK項目),這樣一來不但可以大大提高應用新版本的覆蓋率,也減少了服務器對舊版本接口兼容的壓力,同時如果也可以快速修復一些線上的BUG。

這種技術並不是常規的Android開發方式,早期並沒有完善的解決方案。從“不明覺厲”到穩定投入生產,一直以來我總想對此編寫一些文檔,這也是這篇日誌的由來,沒想到前前後後竟然拖沓着編輯了一年多,所以日誌裏有的地方思路可能有點銜接得不是很好,如果有修正建議請直接回復。

技術背景

通過服務器配置一些參數,Android APP獲取這些參數再做出相應的邏輯,這是常有的事情。

比如現在大部分APP都有一個啓動頁面,如果到了一些重要的節日,APP的服務器會配置一些與時節相關的圖片,APP啓動時候再把原有的啓動圖換成這些新的圖片,這樣就能提高用戶的體驗了。

再則,早期個人開發者在安卓市場上發佈應用的時候,如果應用裏包含有廣告,那麼有可能會審覈不通過。那麼就通過在服務器配置一個開關,審覈應用的時候先把開關關閉,這樣應用就不會顯示廣告了;安卓市場審覈通過後,再把服務器的廣告開關給打開,以這樣的手段規避市場的審覈。

道高一尺魔高一丈。安卓市場開始掃描APK裏面的Manifest甚至dex文件,查看開發者的APK包裏是否有廣告的代碼,如果有就有可能審覈不通過。

通過服務器怕配置開關參數的方法行不通了,開發者們開始想,“既然這樣,能不能先不要在APK寫廣告的代碼,在用戶運行APP的時候,再從服務器下載廣告的代碼,運行,再現實廣告呢?”。答案是肯定的,這就是動態加載:

在程序運行的時候,加載一些程序自身原本不存在的可執行文件並運行這些文件裏的代碼邏輯。

看起來就像是應用從服務器下載了一些代碼,然後再執行這些代碼!

Android應用的動態加載技術

Android應用類似於Java程序,虛擬機換成了Dalvik/ART,而Jar換成了Dex。在Android APP運行的時候,我們是不是也可以通過下載新的應用,或者通過調用外部的Dex文件來實現動態加載呢?

然而在Android上實現起來可沒那麼容易,如果下載一個新的APK下來,不安裝這個APK的話可不能運行。如果讓用戶手動安裝完這個APK再啓動,那可不像是動態加載,純粹就是用戶安裝了一個新的應用,然後再啓動這個新的應用(這種做法也叫做“靜默安裝”)。

動態調用外部的Dex文件則是完全沒有問題的。在APK文件中往往有一個或者多個Dex文件,我們寫的每一句代碼都會被編譯到這些文件裏面,Android應用運行的時候就是通過執行這些Dex文件完成應用的功能的。雖然一個APK一旦構建出來,我們是無法更換裏面的Dex文件的,但是我們可以通過加載外部的Dex文件來實現動態加載,這個外部文件可以放在外部存儲,或者從網絡下載。

動態加載的定義

開始正題之前,在這裏可以先給動態加載技術做一個簡單的定義。真正的動態加載應該是

  1. 應用在運行的時候通過加載一些本地不存在的可執行文件實現一些特定的功能;
  2. 這些可執行文件是可以替換的;
  3. 更換靜態資源(比如換啓動圖、換主題、或者用服務器參數開關控制廣告的隱藏現實等)不屬於 動態加載;
  4. Android中動態加載的核心思想是動態調用外部的 dex文件,極端的情況下,Android APK自身帶有的Dex文件只是一個程序的入口(或者說空殼),所有的功能都通過從服務器下載最新的Dex文件完成;

Android動態加載的類型

Android項目中,動態加載技術按照加載的可執行文件的不同大致可以分爲兩種:

  1. 動態加載so庫;
  2. 動態加載dex/jar/apk文件(現在動態加載普遍說的是這種);

其一,Android中NDK中其實就使用了動態加載,動態加載.so庫並通過JNI調用其封裝好的方法。後者一般是由C/C++編譯而成,運行在Native層,效率會比執行在虛擬機層的Java代碼高很多,所以Android中經常通過動態加載.so庫來完成一些對性能比較有需求的工作(比如T9搜索、或者Bitmap的解碼、圖片高斯模糊處理等)。此外,由於so庫是由C/C++編譯而來的,只能被反編譯成彙編代碼,相比中dex文件反編譯得到的Smali代碼更難被破解,因此so庫也可以被用於安全領域。這裏爲後面要講的內容提前說明一下,一般情況下我們是把so庫一併打包在APK內部的,但是so庫其實也是可以從外部存儲文件加載的。

其二,“基於ClassLoader的動態加載dex/jar/apk文件”,就是我們上面提到的“在Android中動態加載由Java代碼編譯而來的dex包並執行其中的代碼邏輯”,這是常規Android開發比較少用到的一種技術,目前網絡上大多文章說到的動態加載指的就是這種(後面我們談到“動態加載”如果沒有特別指定,均默認是這種)。

Android項目中,所有Java代碼都會被編譯成dex文件,Android應用運行時,就是通過執行dex文件裏的業務代碼邏輯來工作的。使用動態加載技術可以在Android應用運行時加載外部的dex文件,而通過網絡下載新的dex文件並替換原有的dex文件就可以達到不安裝新APK文件就升級應用(改變代碼邏輯)的目的。同時,使用動態加載技術,一般來說會使得Android開發工作變得更加複雜,這中開發方式不是官方推薦的,不是目前主流的Android開發方式,Github 和 StackOverflow 上面外國的開發者也對此不是很感興趣,外國相關的教程更是少得可憐,目前只有在大天朝纔有比較深入的研究和應用,特別是一些SDK組件項目和 BAT家族 的項目上,Github上的相關開源項目基本是國人在維護,偶爾有幾個外國人請求更新英文文檔。

Android動態加載的大致過程

無論上面的哪種動態加載,其實基本原理都是在程序運行時加載一些外部的可執行的文件,然後調用這些文件的某個方法執行業務邏輯。需要說明的是,因爲文件是可執行的(so庫或者dex包,也就是一種動態鏈接庫),出於安全問題,Android並不允許直接加載手機外部存儲這類noexec(不可執行)存儲路徑上的可執行文件。

對於這些外部的可執行文件,在Android應用中調用它們前,都要先把他們拷貝到data/packagename/內部儲存文件路徑,確保庫不會被第三方應用惡意修改或攔截,然後再將他們加載到當前的運行環境並調用需要的方法執行相應的邏輯,從而實現動態調用。

動態加載的大致過程就是:

  1. 把可執行文件(.so/dex/jar/apk)拷貝到應用APP內部存儲;
  2. 加載可執行文件;
  3. 調用具體的方法執行業務邏輯;

以下分別對這兩種動態加載的實現方式做比較深入的介紹。

動態加載 so庫

動態加載so庫應該就是Android最早期的動態加載了,不過so庫不僅可以存放在APK文件內部,還可以存放在外部存儲。Android開發中,更換so庫的情形並不多,但是可以通過把so庫挪動到APK外部,減少APK的體積,畢竟許多so庫文件的體積可是非常大的。

詳細的應用方式請參考後續日誌 Android動態加載補充 加載SD卡的SO庫

動態加載 dex/jar/apk文件

我們經常講到的那種Android動態加載技術就是這種,後面我們談到“動態加載”如果沒有特別指定,均默認是這個。

基礎知識:類加載器ClassLoader和dex文件

動態加載dex/jar/apk文件的基礎是類加載器ClassLoader,它的包路徑是java.lang,由此可見其重要性,虛擬機就是通過類加載器加載其需要用的Class,這是Java程序運行的基礎。

關於類加載器ClassLoader的工作機制,請參考 Android動態加載基礎 ClassLoader的工作機制

現在網上有多種基於ClassLoader的Android動態加載的開源項目,大部分核心思想都殊途同歸,按照複雜程度以及具體實現的框架,大致可以分爲以下三種形式,或者說模式 [1]。

簡單的動態加載模式

理解ClassLoader的工作機制後,我們知道了Android應用在運行時使用ClassLoader動態加載外部的dex文件非常簡單,不用覆蓋安裝新的APK,就可以更改APP的代碼邏輯。但是Android卻很難使用插件APK裏的res資源,這意味着無法使用新的XML佈局等資源,同時由於無法更改本地的Manifest清單文件,所以無法啓動新的Activity等組件。

不過可以先把要用到的全部res資源都放到主APK裏面,同時把所有需要的Activity先全部寫進Manifest裏,只通過動態加載更新代碼,不更新res資源,如果需要改動UI界面,可以通過使用純Java代碼創建佈局的方式繞開XML佈局。同時也可以使用Fragment代替Activity,這樣可以最大限度得避開“無法註冊新組件的限制”。

某種程度上,簡單的動態加載功能已經能滿足部分業務需求了,特別是一些早期的Android項目,那時候Android的技術還不是很成熟,而且早期的Android設備更是有大量的兼容性問題(做過Android1.6兼容的同學可能深有體會),只有這種簡單的加載方式才能穩定運行。這種模式的框架比較適用一些UI變化比較少的項目,比如遊戲SDK,基本就只有登陸、註冊界面,而且基本不會變動,更新的往往只有代碼邏輯。

詳細的應用方式請參考後續日誌 Android動態加載入門 簡單加載模式

代理Activity模式

簡單加載模式還是不夠用,所以代理模式出現了。從這個階段開始就稍微有點“黑科技”的味道了,比如我們可以通過動態加載,讓現在的Android應用啓動一些“新”的Activity,甚至不用安裝就啓動一個“新”的APK。宿主APK[2]需要先註冊一個空殼的Activity用於代理執行插件APK的Activity的生命週期。

主要有以下特點:

  1. 宿主APK可以啓動未安裝的插件APK;
  2. 插件APK也可以作爲一個普通APK安裝並且啓動;
  3. 插件APK可以調用宿主APK裏的一些功能;
  4. 宿主APK和插件APK都要接入一套指定的接口框架才能實現以上功能;

同時也主要有一下幾點限制:

  1. 需要在Manifest註冊的功能都無法在插件實現,比如應用權限、LaunchMode、靜態廣播等;
  2. 宿主一個代理用的Activity難以滿足插件一些特殊的Activity的需求,插件Activity的開發受限於代理Activity;
  3. 宿主項目和插件項目的開發都要接入共同的框架,大多時候,插件需要依附宿主才能運行,無法獨立運行;

詳細的應用方式請參考後續日誌 Android動態加載進階 代理Activity模式

代理Activity模式的核心在於“使用宿主的一個代理Activity爲插件所有的Activity提供組件工作需要的環境”,隨着代理模式的逐漸成熟,現在還出現了“使用Hack手段給插件的Activity注入環境”的模式,這裏暫時不展開,以後會繼續分析。

我們目前有投入到生產中的開發方式只有簡單模式和代理模式,在設計的前期遇到不少兼容性的問題,不過好在Android 4.0以後的機型上就比較少了。

動態創建Activity模式

天了嚕,到了這個階段就真的是“黑科技”的領域了,從而使其可以正常運行。可以試想“從網絡下載一個Flappy Bird的APK,不用安裝就直接運行遊戲”,或者“同時運行兩個甚至多個微信”。

動態創建Activity模式的核心是“運行時字節碼操作”,現在宿主註冊一個不存在的Activity,啓動插件的某個Activity時都把想要啓動的Activity替換成前面註冊的Activity,從而是後者能正常啓動。

這個模式有以下特點:

  1. 主APK可以啓動一個未安裝的插件APK;
  2. 插件APK可以是任意第三方APK,無需接入指定的接口,理所當然也可以獨立運行;

詳細的應用方式請參考後續日誌 Android動態加載黑科技 動態創建Activity模式

爲什麼我們要使用動態加載技術

說實話,作爲開發我們也不想使用的,這是產品要求的!(警察蜀黍就是他,他只問我能不能實現,並木有問我實現起來難不難……好吧我們知道他們也沒得選。)

Android開發中,最先使用動態加載技術的應該是SDK項目吧。現在網上有一大堆Android SDK項目,比如Google的Goole Play Service,向開發者提供支付、地圖等功能,又比如一些Android遊戲市場的SDK,用於向遊戲開發者提供賬號和支付功能。和普通Android應用一樣,這些SDK項目也是要升級的,比如現在別人的Android應用裏使用了我們的SDK1.0版本,然後發佈到安卓市場上去。現在我們發現SDK1.0有一些緊急的BUG,所以升級了一個SDK1.1版本,沒辦法,只能讓人家重新接入1.1版本再發布到市場。萬一我們有SDK1.2、1.3等版本呢,本來讓人家每個版本都重新接入也無可厚非,不過產品可關心體驗啊,他就會問咯,“雖然我不懂技術,但是我想知道有沒有辦法,能讓人家只接入一次我們的SDK,以後我們發佈新的SDK版本的時候他們的項目也能跟着自動升級?”,答曰,“有,使用動態加載的技術能辦到,只不過(開發工作量會劇增…)”,“那就用吧,我們要把產品的體驗做到極致”。

好吧,我並沒有黑產品的意思,現在團隊的產品也不錯,不過與上面類似的對話確實發生在我以前的項目裏。這裏提出來只是爲了強調一下Android項目中採用動態加載技術的 作用 以及由此帶來的 代價。

作用與代價

凡事都有兩面性,特別是這種 非官方支持 的 非常規 開發方式,在採用前一定要權衡清楚其作用與代價。如果決定了要採用動態加載技術,個人推薦可以現在實際項目的一些比較獨立的模塊使用這種框架,把遇到的一些問題解決之後,再慢慢引進到項目的核心模塊;如果遇到了一些無法跨越的問題,要有能夠迅速投入生產的替代方案。

作用

  1. 規避APK覆蓋安裝的升級過程,提高用戶體驗,順便能 規避 一些安卓市場的限制;
  2. 動態修復應用的一些 緊急BUG,做好最後一道保障;
  3. 當應用體積太龐大的時候,可以把一些模塊通過動態加載以插件的形式分割出去,這樣可以減少主項目的體積,提高項目的編譯速度,也能讓主項目和插件項目並行開發;
  4. 插件模塊可以用懶加載的方式在需要的時候才初始化,從而 提高應用的啓動速度;
  5. 從項目管理上來看,分割插件模塊的方式做到了 項目級別的代碼分離,大大降低模塊之間的耦合度,同一個項目能夠分割出不同模塊在多個開發團隊之間 並行開發,如果出現BUG也容易定位問題;
  6. 在Android應用上 推廣 其他應用的時候,可以使用動態加載技術讓用戶優先體驗新應用的功能,而不用下載並安裝全新的APK;
  7. 減少主項目DEX的方法數,65535問題 徹底成爲歷史(雖然現在在Android Studio中很容易開啓MultiDex,這個問題也不難解決);

代價

  1. 開發方式可能變得比較詭異、繁瑣,與常規開發方式不同;
  2. 隨着動態加載框架複雜程度的加深,項目的構建過程也變得複雜,有可能要主項目和插件項目分別構建,再整合到一起;
  3. 由於插件項目是獨立開發的,當主項目加載插件運行時,插件的運行環境已經完全不同,代碼邏輯容易出現BUG,而且在主項目中調試插件十分繁瑣;
  4. 非常規的開發方式,有些框架使用反射強行調用了部分Android系統Framework層的代碼,部分Android ROM可能已經改動了這些代碼,所以有存在兼容性問題的風險,特別是在一些古老Android設備和部分三星的手機上;
  5. 採用動態加載的插件在使用系統資源(特別是Theme)時經常有一些兼容性問題,特別是部分三星的手機;

其他動態修改代碼的技術

上面說到的都是基於ClassLoader的動態加載技術(除了加載SO庫外),使用ClassLoader的一個特點就是,如果程序不重新啓動,加載過一次的類就無法重新加載。因此,如果使用ClassLoader來動態升級APP或者動態修復BUG,都需要重新啓動APP才能生效。

除了使用ClassLoader外,還可以使用jni hook的方式修改程序的執行代碼。前者是在虛擬機上操作的,而後者做的已經是Native層級的工作了,直接修改應用運行時的內存地址,所以使用jni hook的方式時,不用重新應用就能生效。

目前採用jni hook方案的項目中比較熱門的有阿里的dexposed和AndFix,有興趣的同學可以參考 各大熱補丁方案分析和比較

腳註

[1] 其實也說不上什麼模式,這不過這些動態加載的開發方式都有自己明顯的特徵,所以姑且用“形式或者模式”來稱呼好了。

[2] 爲了方便區分概念,闡述一些術語:
宿主:Host,主項目APK、主APK,也就是我們希望採用動態加載技術的主項目;
插件:Plugin,可以是dex、jar或者apk文件,從主項目分離開來,我們能通過動態加載加載到主項目裏面來的模塊,一個主APK可以同時加載多個插件APK;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章