插件框架-RePlugin源碼閱讀

寫在前面

==如果時(shí)間有限可以直接跳到最下面的 核心問題==

* 插件化現(xiàn)狀

插件化目前的處境肯定是大不如前午笛,由于android系統(tǒng)逐步完善收緊各種黑科技很難再爆發(fā)搜骡,各個(gè)插件化逐步從爆發(fā)大量黑科技到追求穩(wěn)定性,再加之小程序的產(chǎn)生。讓大廠很多合作直接使用小程序察绷,而不再使用插件化.
不過對(duì)于中小公司沒有小程序能力的炮叶,插件化不失為一種比較好的動(dòng)態(tài)化方案。

*為什么閱讀RePlugin的源碼

對(duì)比了VirtualApk和RePlugin贷揽,選擇閱讀RePlugin的源碼是因?yàn)樘男Γ萊ePlugin 有更大的概率還在維護(hù)中,而且wiki中有已經(jīng)整理好的原理性的文章禽绪,方便閱讀

為什么不選擇閱讀VirtualApp的源碼腐晾?雖然他是第三代插件化框架,但是目前收費(fèi)的丐一,閱讀源碼還是希望使用在項(xiàng)目中藻糖。有時(shí)間可以閱讀以下VirtualApp的 免費(fèi)版本

插件化無疑就是在解決,如何讓宿主app使用到 插件的 類库车,資源巨柒。這樣的問題..(核心問題部分中有回答)我們本著這個(gè)核心思想去閱讀源碼應(yīng)該會(huì)有更好的效果

* RePlugin解決了什么問題?

RePlugin解決的是各個(gè)功能模塊能獨(dú)立升級(jí)柠衍,又能需要和宿主洋满、插件之間有一定交互和耦合(所以開發(fā)需要按照一定的規(guī)則)。有別與類似 VirtualApk 這種雙開類型的插件化框架(可以將任一APP作為插件)

感嘆

RePlugin應(yīng)該是我現(xiàn)在閱讀的除了Android源碼之外最復(fù)雜的源碼了珍坊,不過確實(shí)寫得挺好的牺勾,有很多地方值得學(xué)習(xí),尤其是在程序的健壯性和兼容性方面阵漏。

版本

v2.3.3

參考


RePlugin原理簡介

Replugin的整體框架使用了Binder機(jī)制來進(jìn)行宿主和多插件之間交互通信和數(shù)據(jù)共享闻葵,這里如果了解android四大組件的運(yùn)行流程的話,看完了Replugin的源碼后會(huì)感覺非常像簡易ServiceManager和AMS的結(jié)構(gòu)癣丧。

Replugin默認(rèn)會(huì)使用一個(gè)常駐進(jìn)程作為Server端槽畔,其他插件進(jìn)程和宿主進(jìn)程全部屬于Client端。當(dāng)然如果修改不使用常駐進(jìn)程坎缭,那么宿主的主進(jìn)程將作為插件管理進(jìn)程,而不管是使用宿主進(jìn)程還是使用默認(rèn)的常駐進(jìn)程,Server端其實(shí)就是創(chuàng)建了一個(gè)運(yùn)行在該進(jìn)程中的Provider竟痰,通過Provider的query方法返回了Binder對(duì)象來實(shí)現(xiàn)多進(jìn)程直接的的溝通和數(shù)據(jù)共享,或者說是插件之間和宿主之間溝通和數(shù)據(jù)共享掏呼,插件的安裝坏快,卸載,更新憎夷,狀態(tài)判斷等全部都在這個(gè)Server端完成莽鸿。

其實(shí)Replugin還是使用的占坑的方式來實(shí)現(xiàn)的插件化,replugin-host-gradle這個(gè)gradle插件會(huì)在編譯的時(shí)候自動(dòng)將坑位信息生成在主工程的AndroidManifest.xml中拾给,Replugin的唯一hook點(diǎn)是hook了系統(tǒng)了ClassLoader祥得,當(dāng)啟動(dòng)四大組件的時(shí)候會(huì)通過Clent端發(fā)起遠(yuǎn)程調(diào)用去Server做一系列的事情,例如檢測(cè)插件是否安裝蒋得,安裝插件级及,提取優(yōu)化dex文件,分配坑位额衙,啟動(dòng)坑位饮焦,這樣可以欺騙系統(tǒng)達(dá)到不在AndroidManifest.xml注冊(cè)的效果,最后在Clent端加載要被啟動(dòng)的四大組件窍侧,因?yàn)橐呀?jīng)hook了系統(tǒng)的ClassLoader县踢,所以可以對(duì)系統(tǒng)的類加載過程進(jìn)行攔截,將之前分配的坑位信息替換成真正要啟動(dòng)的組件信息并使用與之對(duì)應(yīng)的ClassLoader來進(jìn)行類的加載伟件,從而啟動(dòng)未在AndroidManifest.xml中注冊(cè)的組件硼啤。

各個(gè)工程模塊職責(zé)簡要解析

  • replugin-host-gradle :
    主程序使用的Gradle插件,主要職責(zé)是在我們的主程序打包的過程中(編譯的過程中)動(dòng)態(tài)的修改AndroidManifest.xml的信息斧账,動(dòng)態(tài)的生成占位各種Activity谴返、provider和service的聲明。
  • replugin-host-library :
    這個(gè)庫是要由主程序依賴的其骄,也是Replugin的核心亏镰,它的主要職責(zé)是初始化Replugin的整體框架,整體框架使用了Binder機(jī)制來實(shí)現(xiàn)多進(jìn)程直接的的溝通和數(shù)據(jù)共享拯爽,或者說是插件之間和宿主之間溝通和數(shù)據(jù)共享,hook住ClassLoader钧忽,加載插件毯炮、啟動(dòng)插件逼肯、多插件的管理全部都與這個(gè)庫輔助
  • replugin-plugin-gradle :
    這個(gè)是插件工程使用的Gradle的插件,這個(gè)庫使用了Transfrom API和Javassist實(shí)現(xiàn)了編譯期間動(dòng)態(tài)的修改字節(jié)碼文件桃煎,主要是替換插件工程中的Activity的繼承全部替換成Replugin庫中定義的XXXActivity篮幢。動(dòng)態(tài)的將插件apk中調(diào)用LocalBroadcastManager的地方修改為Replugin中的PluginLocalBroadcastManager調(diào)用,動(dòng)態(tài)修改ContentResolver和ContentProviderClient的調(diào)用修改成Replugin調(diào)用为迈,動(dòng)態(tài)的修改插件工程中所有調(diào)用Resource.getIdentifier方法的地方三椿,將第三參數(shù)修改為插件工程的包名
  • replugin-plugin-library :
    這個(gè)庫是由插件工程依賴的,這個(gè)庫的主要目的是通過反射的方式來使用主程序中接口和功能葫辐,這個(gè)庫在出程序加載加載插件apk后會(huì)進(jìn)行初始化搜锰。

各模塊解析

replugin-host-gradle

主要職責(zé)

  1. 創(chuàng)建 rpShowPlugin... Task 用于將 插件信息寫入到 plugins-builtin.json 文件中
  2. 創(chuàng)建 rpGenerateHostConfig Task用于生產(chǎn) RePluginHostConfig.java 文件,文件內(nèi)容基本就是 用戶配置的信息(坑位信息耿战,進(jìn)程名稱等)
  3. 修改manifast.xml 植入占坑信息

replugin-host-library

各類職責(zé)

運(yùn)行于常駐進(jìn)程 (常駐進(jìn)程主要用于插件管理和Service(四大組件)維護(hù))
  • PmHostSvc(binder對(duì)象):這個(gè)類可以理解成是我們的Server端蛋叼,它直接或間接參與了Server端要做的所有事情
  • PluginServiceServer(binder對(duì)象):主要負(fù)責(zé)了對(duì)Service的提供和調(diào)度工作,例如startService剂陡、stopService狈涮、bindService、unbindService全部都由這個(gè)類管理
  • PluginManagerServer(binder對(duì)象):掌管了所有對(duì)插件的的操作鸭栖,例如插件的安裝歌馍、加載、卸載晕鹊、更新等等
  • Builder.PxAll : 緩存所有(各種類型)插件
運(yùn)行于ui進(jìn)程
  • RePlugin:RePlugin的對(duì)外入口類 松却,宿主App可直接調(diào)用此類中的方法,來使用插件化的幾乎全部的邏輯捏题。
  • IPC:用于“進(jìn)程間通信”的類玻褪。插件和宿主可使用此類來做一些跨進(jìn)程發(fā)送廣播、判斷進(jìn)程等工作公荧。
  • PMF:框架和主程序接口代碼
  • PmBase:具有很多重要的功能,例如:分配坑位带射、初始化插件信息、Clent端連接Server端循狰、加載插件窟社、更新插件、刪除插件绪钥、等等
  • PluginProcessPer:它是一個(gè)Binder對(duì)象灿里,它代表了“當(dāng)前Clent端”,使用它來和Server端進(jìn)行通信
  • LaunchModeStates:存儲(chǔ) LaunchMode + Theme -> 此種組合下的 ActivityState 狀態(tài)集合
  • ActivityState:坑位與真實(shí)組件之間的對(duì)應(yīng)關(guān)系
  • PluginContainers:用來管理Activity坑位信息的容器程腹,初始化了多種不同啟動(dòng)模式和樣式Activity的坑位信息匣吊。
  • PluginCommImpl:負(fù)責(zé)宿主與插件、插件間的互通,很多對(duì)提供方法都經(jīng)過這里中轉(zhuǎn)或者最終調(diào)到這里
  • PluginLibraryInternalProxy:Replugin框架中內(nèi)部邏輯使用的很多方法都在這里色鸳,包括插件中通過“反射”調(diào)用的內(nèi)部邏輯如PluginActivity類的調(diào)用社痛、Factory2等
  • RePluginClassLoader:用于替代宿主原有PathClassLoader的工作
  • PluginDexClassLoader:個(gè)用來加載插件apk的類
  • PluginProcessMain:進(jìn)程管理類
  • IPluginManagerServer(aidl文件):插件管理器。用來控制插件的安裝命雀、卸載蒜哀、獲取等。運(yùn)行在常駐進(jìn)程中
  • IPluginHost(aidl文件):涉及到插件交互吏砂、運(yùn)行機(jī)制有關(guān)的管理器
  • StubProcessManager:坑位進(jìn)程管理
  • PluginManagerProxy:用于各進(jìn)程(包括常駐自己)緩存 PluginManagerServer 的Binder實(shí)現(xiàn)
  • PluginContext:插件要用的 Context
  • PluginApplicationClient: 一種能處理【插件】的Application的類
  • RePluginInternal:主要功能是緩存了 Context(宿主Application) 對(duì)象撵儿,并對(duì)外提供
  • PluginInfo:用來描述插件,通過解析json生成
  • PluginProviderStub:用于客戶端進(jìn)程通過 ContentProvider 獲取常駐進(jìn)程 binder對(duì)象等操作

replugin-plugin-gradle

主要職責(zé)

  1. 創(chuàng)建調(diào)試用的各個(gè)task
    • 強(qiáng)制停止宿主程序: rpForceStopHostApp
    • 安裝插件到宿主并運(yùn)行(常用任務(wù)): rpInstallAndRunPluginDebug或rpInstallAndRunPluginRelease等
    • 僅僅安裝插件到宿主: rpInstallPluginDebug或rpInstallPluginRelease等
    • rpRestartHostApp
      重啟宿主程序
    • 僅僅運(yùn)行插件狐血,如果插件前面沒安裝淀歇,則執(zhí)行不成功:rpRunPluginDebug或rpRunPluginRelease等
    • 啟動(dòng)宿主程序:rpStartHostApp
    • 僅僅卸載插件,如果完全卸載氛雪,還需要執(zhí)行rpRestartHostApp任務(wù): rpUninstallPluginDebug或rpUninstallPluginRelease
  2. 使用了Transfrom API和Javassist實(shí)現(xiàn)了編譯期間動(dòng)態(tài)的修改字節(jié)碼文件房匆,主要是
    • 替換插件工程中的Activity的繼承全部替換成Replugin庫中定義的XXXActivity(如PluginActivity)。
    • 動(dòng)態(tài)的將插件apk中調(diào)用LocalBroadcastManager的地方修改為Replugin中的PluginLocalBroadcastManager調(diào)用报亩,(被修改的包含一些系統(tǒng)類 比如LocalBroadcastManager的sendBroadcastSync 就被替換了浴鸿。)
    • 動(dòng)態(tài)修改ContentResolver和ContentProviderClient的調(diào)用修改成Replugin 自定義的調(diào)用調(diào)用,(被修改的包含一些系統(tǒng)類)
    • 動(dòng)態(tài)的修改插件工程中所有調(diào)用Resource.getIdentifier方法的地方弦追,將第三個(gè)參數(shù)修改為插件工程的包名(被修改的包含一些系統(tǒng)類)

replugin-plugin-library

各類職責(zé)

  • Entry:宿主框架最先調(diào)用的類 用于初始化框架和環(huán)境
  • RePluginServiceManager:插件內(nèi)部向外提供服務(wù)的管理實(shí)現(xiàn)類

大體工作流程

  1. 宿主APP啟動(dòng)時(shí)加載插件(解析插件信息但是不適用)岳链,和緩存預(yù)埋坑位
  2. 在使用插件時(shí) 選擇合適坑位

閱讀要點(diǎn)

標(biāo)識(shí)解讀

  • N1 : UI 進(jìn)程標(biāo)識(shí)
  • P{n} : 自定義進(jìn)程標(biāo)識(shí)
  • NR : launchMode為 Standard
  • STP: launchMode為 LAUNCH_SINGLE_TOP
  • ST: launchMode為 LAUNCH_SINGLE_TASK
  • SI:launchMode為 LAUNCH_SINGLE_INSTANCE
  • NTS :表示坑的 theme 為不透明
  • TS:表示坑的 theme 為透明
  • p_n插件:
  • 純APP插件:

內(nèi)部存儲(chǔ)中各個(gè)文件夾的含義

  • app_plugins_v3_libs:內(nèi)置插件等的 so文件存放目錄
  • app_p_c:存放可以覆蓋更新的插件 so文件
  • app_p_n:純"APK"插件的 so文件存放目錄?

閱讀時(shí)注意的點(diǎn)

  • 每一個(gè)插件(Plugin)都由一個(gè)Loader劲件,每個(gè) Loader都由一個(gè) ComponentList

調(diào)用鏈

host初始化

  • RePluginApplication.attachBaseContext
    • RePlugin.App.attachBaseContext
      • IPC.init() : 確認(rèn)常駐進(jìn)程名 和 當(dāng)前進(jìn)程是那種進(jìn)程(主進(jìn)程or常駐進(jìn)程or其他)
      • PMF.init:
        • PluginManager.init
          • 初始化主線程handler
          • 通過當(dāng)前進(jìn)程的名字 獲取 進(jìn)程對(duì)應(yīng)的 int值
        • PmBase.<init> : (所有進(jìn)程都會(huì)調(diào)用)
          • PluginProcessPer.<init> :
            • PluginServiceServer.<init>
            • PluginContainers.init() : 初始化坑位
          • PluginCommImpl.<init>
          • PluginLibraryInternalProxy.<init>
        • PmBase.init() : 判斷是否使用常駐進(jìn)程作為服務(wù)進(jìn)程掸哑,并對(duì)服務(wù)進(jìn)程和客戶進(jìn)程分別初始化
          • PmBase.initForServer(常駐進(jìn)程中的操作) : 初始化服務(wù)進(jìn)程,最主要的操作就是 將所有插件信息緩存到 PmBase.mPlugins 數(shù)組中
            • Builder.builder : 整理插件并緩存到 PxAll中
            • PmBase.refreshPluginMap : 將插件信息全部緩存到 mPlugins 中
            • PluginManagerProxy.load : 加載純 APP插件零远?
            • PmBase.refreshPluginMap : 更新 mPlugins 中信息
          • PmBase.initForClient(客戶端進(jìn)程中的操作):1. 鏈接常駐進(jìn)程苗分,2. 獲取插件信息
            • PluginProcessMain.connectToHostSvc(): 連接常駐進(jìn)程(初始化用于通信的binder代理對(duì)象)
              • PluginProviderStub.proxyFetchHostBinder : 獲取常駐進(jìn)程b PmHostSvc inder 對(duì)象
              • IPluginHost.Stub.asInterface(binder) : 獲取PmHostSvc inder 對(duì)象的代理對(duì)象
              • PluginManagerProxy.connectToServer : 初始化 PluginManagerProxy.sRemote 對(duì)象用于和常駐進(jìn)程通信
              • PluginManagerProxy.syncRunningPlugins() : 和常駐進(jìn)程同步插件運(yùn)行列表
              • PmBase.attach : 注冊(cè)該進(jìn)程信息到“插件管理進(jìn)程”中?
            • PmBase.refreshPluginsFromHostSvc : 從常駐進(jìn)程獲取插件列表,將插件信息全部緩存到 mPlugins 中
          • PluginTable.initPlugins : 創(chuàng)建一份 最新快照到 PluginTable.PLUGINS
        • PatchClassLoaderUtils.patch : hook App的classLoader 為 RePluginClassLoader
      • PMF.callAttach()
        • PmBase.callAttach()
          • Plugin.load(); 加載并啟動(dòng)插件
            • Plugin.loadLocked() 加載插件
              • Plugin.doLoad(): 加載插件信息、資源牵辣、Dex摔癣,并運(yùn)行Entry類
                • Loader.loadDex():
                  • PackageManager.getPackageArchiveInfo : 獲取插件的 PackageInfo
                  • mPackageInfo.applicationInfo.sourceDir : 設(shè)置插件的路徑,這個(gè)地址后面再獲取插件的Resources對(duì)象時(shí)會(huì)用到(設(shè)置之前是空的)
                  • mPackageInfo.applicationInfo.publicSourceDir : 同上
                  • mPackageInfo.applicationInfo.nativeLibraryDir :設(shè)置 so文件存放 路徑 纬向,插件加載so時(shí)會(huì)使用
                  • pm.getResourcesForApplication : 創(chuàng)建插件使用的Resources對(duì)象
                  • RePlugin.getConfig().getCallbacks().createPluginClassLoader : 創(chuàng)建插件的ClassLoader
                  • new PluginContext : 創(chuàng)建插件使用的 Context (PluginContext)择浊,
              • Plugin.loadEntryLocked(): 會(huì)反射加載插件中的Entry類以初始化插件框架和環(huán)境
            • Plugin.callApp(): 啟動(dòng)并初始化插件Application,
              • Plugin.callAppLocked :
                • PluginApplicationClient.getOrCreate : 創(chuàng)建插件的Application對(duì)象
                  • PluginApplicationClient.<init> :
                  • PluginApplicationClient.initCustom : 創(chuàng)建插件的Application對(duì)象
                • PluginApplicationClient.callAttachBaseContext : 將插件使用的Context 通過Application傳給插件 (PluginContext)
  • RePluginApplication.onCreate
    • RePlugin.App.onCreate()
      • PMF.callAppCreate
        • PmBase.callAppCreate
          • 常駐進(jìn)程:獲取cookie
          • 其他進(jìn)程注冊(cè) 安裝插件和卸載插件的廣播
        • PluginInfoUpdater.register():非常駐進(jìn)程注冊(cè)監(jiān)聽PluginInfo變化的廣播以接受來自常駐進(jìn)程的更新

宿主啟動(dòng)插件中某Activity流程

  • RePlugin.startActivity
    • Factory.startActivityWithNoInjectCN
      • PluginCommImpl.startActivity
        • PluginLibraryInternalProxy.startActivity(5個(gè)參數(shù))
          • PluginCommImpl.loadPluginActivity : 找到坑位activity 并封裝為 ComponentName 再返回,這個(gè)過程中還會(huì)給Intent塞一下參數(shù)逾条,比如 插件名稱
            • MP.startPluginProcess : 啟動(dòng)目標(biāo)進(jìn)程 并獲取PluginProcessPer的binder 代理對(duì)象 用于通信
            • client.allocActivityContainer : 遠(yuǎn)程分配坑位并返回
          • context.startActivity : 打開坑位Activity琢岩,這個(gè)時(shí)候App就要調(diào)用classLoader加載坑位Activity了,但是App的ClassLoader被我們hook成為了 RePluginClassLoader ,所以也就是使用 RePluginClassLoader 來加載 坑位activity师脂,下面我么那就繼續(xù)看這個(gè)流程
          • RePluginClassLoader. loadClass : 記載類
            • PMF.loadClass :
              • PmBase.loadClass :
                • PluginProcessPer.resolveActivityClass :
                  • PluginDexClassLoader.loadClass : 使用插件classLoader 加載類

==這樣就達(dá)成了偷梁換柱担孔,系統(tǒng)以為我們加載的是 坑位類江锨,但其實(shí)加載的是 插件目標(biāo)類。而且系統(tǒng)也會(huì)乖乖的替我們 管理 插件目標(biāo)類的 生命周期攒磨。太陰了....==

插件中啟動(dòng)activity

1. 插件中直接或者間接(使用activity中使用view.getContext)通過Activity.startActivity打開宿主Activity
因?yàn)樵诰幾g器 插件中Activity的父類都被改變?yōu)槔^承自 PluginActivity等Replugin 提供的Activity泳桦,所以他們的 startActivity 都會(huì)以 PluginActivity等的 startActivity為起點(diǎn)
  • PluginActivity.PluginActivity
    • RePluginInternal.startActivity
      • ProxyRePluginInternalVar.startActivity.call 汤徽;反射調(diào)用 宿主工程中的 com.qihoo360.i.Factory2.startActivity 方法
        • PluginLibraryInternalProxy.startActivity(2個(gè)參數(shù)) :
          • PluginLibraryInternalProxy.fetchPluginByPitActivity : 獲取插件名
          • Factory.startActivityWithNoInjectCN :
            • PluginCommImpl.startActivity:
              • PluginLibraryInternalProxy.startActivity(5個(gè)參數(shù))
                • PluginCommImpl.loadPluginActivity:執(zhí)行這個(gè)方法時(shí) 因?yàn)闆]有在插件中肯定找不到宿主的Activity 所以會(huì)直接返回false娩缰,然后一層層退出到 RePluginInternal 中
    • super.startActivity : 正常啟動(dòng)activity
2. 插件中使用Application的context打開Activity (==要走兩次PluginContext.startActivity==)

因?yàn)椴寮械腃ontext是在Plugin.callApp()過程中傳遞過去的PluginContext,所以,這個(gè)流程會(huì)以PluginContext.startActivity(Intent intent)為起點(diǎn)

  • PluginContext.startActivity(Intent intent): 第一次 是替換intent中要打開的Activity為 坑位activity
    • Factory2.startActivity(Context context, Intent intent) : 這次返回true
      • PluginLibraryInternalProxy.startActivity(2個(gè)參數(shù)) :
      • PluginLibraryInternalProxy.fetchPluginByPitActivity : 獲取插件名
        • Factory.startActivityWithNoInjectCN :
          • PluginCommImpl.startActivity:
            • PluginLibraryInternalProxy.startActivity(5個(gè)參數(shù)) : 替換目標(biāo)為坑位Activity
              • context.startActivity : 這里又調(diào)用了一次Context.startActivity,所以又回到了 PluginContext.startActivity中
  • PluginContext.startActivity(Intent intent): 第二次是打開坑位activity谒府,欺騙系統(tǒng)打開 目標(biāo)activity
    • Factory2.startActivity : 這次返回false
    • super.startActivity(intent) : 真正的打開activity
可以看到 插件中啟動(dòng)activity最終都走到了 PluginLibraryInternalProxy.startActivity(5個(gè)參數(shù)) 這個(gè)方法
3. 插件中正常(走的是Activity的startActivity)打開插件中的Activity (==要走兩次Activity.startActivity==)
  • PluginActivity.startActivity : 第一次
    • Factory2.startActivity(Activity activity, Intent intent) : 反射調(diào)用 這次返回true
      • PluginLibraryInternalProxy.startActivity(2個(gè)參數(shù)) :
      • PluginLibraryInternalProxy.fetchPluginByPitActivity : 獲取插件名
        • Factory.startActivityWithNoInjectCN :
          • PluginCommImpl.startActivity:
            • PluginLibraryInternalProxy.startActivity(5個(gè)參數(shù)): 替換目標(biāo)為坑位Activity
              • context.startActivity : 這里的 context其實(shí)是 Activity
  • PluginActivity.startActivity : 第二次
    • Factory2.startActivity(Activity activity, Intent intent) : 反射調(diào)用 這次返回false
      • super.startActivity(intent) : 真正的打開activity
4. 插件中打開宿主中Activity

因?yàn)椴寮腸lassLoader 如果找不到類就會(huì)去 宿主中找拼坎,而且 宿主的Activity也已經(jīng)注冊(cè)了,所以直接打開就行

插件activity(繼承自PluginActivity) onCreate 流程

插件在編譯器會(huì)將自己的父類替換為RePlugin內(nèi)容提供的類如PluginActivity

  • RePluginInternal.handleActivityCreateBefore : 對(duì)FragmentActivity做特殊處理
  • super.onCreate() : PluginActivity 父類的 onCreate
  • RePluginInternal.handleActivityCreate : 填充一下必要的東西完疫,比如 lable等泰鸡?

學(xué)到的

1. 如何避免資源id沖突

答:不同的插件設(shè)置不同的packageId(==范圍0x02 - 0x7e,0x01是系統(tǒng)的壳鹤,0x7f是宿主APP的==)盛龄,進(jìn)行區(qū)分

2. hook時(shí)機(jī)完美

感覺hook classLoader的時(shí)機(jī)非常完美,是在Application的attachBaseContext中進(jìn)行hook的 芳誓,這個(gè)時(shí)候是 Appcation剛創(chuàng)建完畢余舶,他的上一步就是創(chuàng)建ContextImpl并保存LoadedApk。感覺非常及時(shí)(锹淌,不過好像也不用這么早只要下個(gè)apk中的類是用自定義classLoader加載的就行匿值?)

3. RePlugin支持插件使用宿主的類

RePlugin 是每一個(gè)Plugin都會(huì)有一個(gè)獨(dú)立的ClassLoader(PluginDexClassLoader),會(huì)優(yōu)先是用自己的classLoader,如果自己找不到了才回去通過父類查找,這樣就支持在不同插件中使用路徑和名字完全相同的類

4. gradle plugin 寫得確實(shí)很優(yōu)雅赂摆,很多之前未見過的寫法挟憔,及gradle 版本兼容,值得學(xué)習(xí)

5. 可以通過gradle task 執(zhí)行adb命令 烟号,然后進(jìn)行一些操作

6. handler.postAtFrontOfQueue 這個(gè) api的意思是 發(fā)送一個(gè)message 而且放到隊(duì)列的最前面

7. 通過反射刪除一個(gè)成員的 finel 修飾符 绊谭,真的是厲害啊

  /**
     * 刪除final修飾符
     * @param field
     */
    public static void removeFieldFinalModifier(final Field field) {
        // From Apache: FieldUtils.removeFinalModifier()
        Validate.isTrue(field != null, "The field must not be null");

        try {
            if (Modifier.isFinal(field.getModifiers())) {//是否是final類型
                // Do all JREs implement Field with a private ivar called "modifiers"?
                final Field modifiersField = Field.class.getDeclaredField("modifiers");
                final boolean doForceAccess = !modifiersField.isAccessible();
                if (doForceAccess) {
                    modifiersField.setAccessible(true);
                }
                try {
                    modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
                } finally {
                    if (doForceAccess) {
                        modifiersField.setAccessible(false);
                    }
                }
            }
        } catch (final NoSuchFieldException ignored) {
            // The field class contains always a modifiers field
        } catch (final IllegalAccessException ignored) {
            // The modifiers field is made accessible
        }
    }

8. Intent的Component 可以用來傳遞打開Activity的源頭參考

9. DexClassLoader 的 optimizedDirectory 和 librarySearchPath 只需要我們指定,不用我們自己去創(chuàng)建汪拥,并解析apk


==核心問題==

1. 宿主如何加載插件的類和so文件达传?

答:首先明確類和so文件都是通過ClassLoader進(jìn)行加載的,RePlugin中 有兩個(gè)ClassLoader 一個(gè)是宿主的 RePluginClassLoader 他是hook了App的原始 ClassLoader喷楣,一個(gè)是加載插件Class的 PluginDexClassLoader趟大。當(dāng)宿主通過RePluginClassLoader加載一個(gè)插件里的類時(shí),它先會(huì)去使用插件的PluginDexClassLoader去加載铣焊,如果找到了就直接返回逊朽,如果找不到才會(huì)去自己進(jìn)行加載。具體的可以跟著上面 《宿主啟動(dòng)插件中某Activity流程》走一遍就知道了曲伊。

至于為什么 DexClassLoader叽讳,其實(shí)就是因?yàn)?DexClassLoader 在初始化的時(shí)候可以傳入一個(gè)已經(jīng)優(yōu)化過的dex文件路徑追他,就可以加載它。 可以動(dòng)態(tài)化可以參考

2. 插件中的資源是如何找到并加載的岛蚤? 以layout為例

2.1 插件Activity加載自己的layout文件 (比如:demo1插件的 MainActivity 加載 自己的 R.layout.main layout)

首先Activity在創(chuàng)建的時(shí)候會(huì)創(chuàng)建一個(gè) PhoneWindow 邑狸,PhoneWindow在創(chuàng)建的時(shí)候回創(chuàng)建一個(gè) LayoutInflater,這個(gè)過程中都傳遞了一個(gè)Context涤妒,LayoutInflater 會(huì)將這個(gè)Context記錄下來也就是mContext,這個(gè)Context其實(shí)就是 Activity 的Context单雾。 setContentView( R.layout.main) 最后會(huì)調(diào)用到 LayoutInflater.infalte()方法,這個(gè)時(shí)候 就會(huì)通過mContext.getResources()獲取 Resources 對(duì)象她紫,期間會(huì)調(diào)用到Activity的mBase.getResources方法硅堆,最終會(huì)調(diào)用到ContextImpl的 getResources()方法。

==2.2.1 系統(tǒng)正常啟動(dòng)apk的情況下==

上面提到Resources的獲取最終是通過ContextImpl.getResources()方法獲取贿讹,而ContextImpl中的mResources對(duì)象是在構(gòu)造方法中通過LoadedApk.getResources()方法初始化的如下:

    public Resources getResources(ActivityThread mainThread) {
        if (mResources == null) {
            //通過ApplicationInfo中的 一些文件夾創(chuàng)建 Resources
            mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs,
                    mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this);
        }
        return mResources;
    }

可以看到創(chuàng)建 Resources 的過程中使用到了 mResDir(apk文件路徑渐逃,在RePlugin中就是插件的路徑) 等這些參數(shù),下面我們先看一下 系統(tǒng)正常啟動(dòng)apk的情況下 mResDir等字段是哪里進(jìn)程賦值的民褂。

我們都知道在系統(tǒng)啟動(dòng)apk的過程中會(huì)通過zygote孵化一個(gè)新的進(jìn)程用于這個(gè)APK的運(yùn)行茄菊,當(dāng)新的進(jìn)程創(chuàng)建完畢需要將Application和這個(gè)進(jìn)程綁定的時(shí)候系統(tǒng)會(huì)調(diào)用ActivityThread.handleBindApplication,我們就從這里還是看

1.1 ActivityThread.handleBindApplication

private void handleBindApplication(AppBindData data) {
         
         ....
         
          InstrumentationInfo ii = null;
            try {
                //通過 PackageManagerService 解析Apk獲取 apk的一些基本信息
                ii = appContext.getPackageManager().
                    getInstrumentationInfo(data.instrumentationName, 0);
            } catch (PackageManager.NameNotFoundException e) {
            }
           

           ....

            //創(chuàng)建 ApplicationInfo 用于記錄APP的基本信息 如赊堪,包名面殖,apk路徑等
            ApplicationInfo instrApp = new ApplicationInfo();
            instrApp.packageName = ii.packageName;
            instrApp.sourceDir = ii.sourceDir;
            instrApp.publicSourceDir = ii.publicSourceDir;
            instrApp.splitSourceDirs = ii.splitSourceDirs;
            instrApp.splitPublicSourceDirs = ii.splitPublicSourceDirs;
            instrApp.dataDir = ii.dataDir;
            instrApp.nativeLibraryDir = ii.nativeLibraryDir;
            //這里創(chuàng)建 LoadedApk 并通過 instrApp記錄的一些信息做一些初始化  詳見【1.2】
            LoadedApk pi = getPackageInfo(instrApp, data.compatInfo,
                    appContext.getClassLoader(), false, true, false);
         
         
         ....
         
          // 此處data.info是指LoadedApk, 通過反射創(chuàng)建目標(biāo)應(yīng)用Application對(duì)象
           Application app = data.info.makeApplication(data.restrictedBackupMode, null);
         }

1.2 ActivityThread.getPackageInfo

 private LoadedApk getPackageInfo(ApplicationInfo aInfo, CompatibilityInfo compatInfo,
            ClassLoader baseLoader, boolean securityViolation, boolean includeCode,
            boolean registerPackage) {
                //創(chuàng)建LoadedApk對(duì)象 詳見【1.3】
                packageInfo =
                    new LoadedApk(this, aInfo, compatInfo, baseLoader,
                            securityViolation, includeCode &&
                            (aInfo.flags&ApplicationInfo.FLAG_HAS_CODE) != 0, registerPackage);
            }

1.3 LoadedApk<init>

    public LoadedApk(ActivityThread activityThread, ApplicationInfo aInfo,
            CompatibilityInfo compatInfo, ClassLoader baseLoader,
            boolean securityViolation, boolean includeCode, boolean registerPackage) {
        final int myUid = Process.myUid();
        aInfo = adjustNativeLibraryPaths(aInfo);

         //ActivityThread對(duì)象
        mActivityThread = activityThread;
        mApplicationInfo = aInfo;
        mPackageName = aInfo.packageName;
        mAppDir = aInfo.sourceDir;
        mResDir = aInfo.uid == myUid ? aInfo.sourceDir : aInfo.publicSourceDir;
        mSplitAppDirs = aInfo.splitSourceDirs;
        mSplitResDirs = aInfo.uid == myUid ? aInfo.splitSourceDirs : aInfo.splitPublicSourceDirs;
        mOverlayDirs = aInfo.resourceDirs;
        mSharedLibraries = aInfo.sharedLibraryFiles;
        mDataDir = aInfo.dataDir;
        mDataDirFile = mDataDir != null ? new File(mDataDir) : null;
        mLibDir = aInfo.nativeLibraryDir;
        mBaseClassLoader = baseLoader;
        mSecurityViolation = securityViolation;
        mIncludeCode = includeCode;
        mRegisterPackage = registerPackage;
        mDisplayAdjustments.setCompatibilityInfo(compatInfo);
    }

從上面的分析得到,系統(tǒng)正常啟動(dòng)Apk的情況下雹食,系統(tǒng)會(huì)在Application創(chuàng)建之前就將 mResDir等信息就賦值給了LoadedApk,后面我們調(diào)用getResources就會(huì)拿到正確的Resources對(duì)象

==2.2.2 看完了正常情況下的畜普,那么RePlugin插件中的Activity是如何正常使用setContentView的呢?==

在上面的描述中我們已經(jīng)知道在Activity中獲取Resources對(duì)象會(huì)通過mBase.getResources()來獲取而且在分replugin-plugin-gradle的時(shí)候我們知道插件在編譯器會(huì)將期繼承的Activity替換為PluginActivity等Replugin內(nèi)部提供的Activity群叶,那么我們來看一下 PluginActivity 中有什么玄機(jī)嗎吃挑?
果真在 PluginActivity中通過如下調(diào)用鏈將Activity的mBase替換成了PluginContext對(duì)象,所以在Activity中獲取Resources對(duì)象最終會(huì)走到PluginContext.getResource

  • PluginActivity.attachBaseContext
    • RePluginInternal.createActivityContext
      • ProxyRePluginInternalVar.createActivityContext.call

PluginContext.getResource方法如下

 public Resources getResources() {
        if (mNewResources != null) {
            return mNewResources;
        }
        return super.getResources();
    }

他只是返回了mNewResources,mNewResources是在PluginContext的構(gòu)造犯法中賦值的街立,PluginContext是通過如下調(diào)用鏈創(chuàng)建的(==具體可以看調(diào)用鏈中的內(nèi)容==)

  • Plugin.load(); 加載并啟動(dòng)插件
    • Plugin.loadLocked() 加載插件
      • Plugin.doLoad(): 加載插件信息舶衬、資源、Dex赎离,并運(yùn)行Entry類
        • Loader.loadDex():
          • PackageManager.getPackageArchiveInfo : 獲取插件的 PackageInfo
          • mPackageInfo.applicationInfo.sourceDir : 設(shè)置插件的路徑逛犹,這個(gè)地址后面再獲取插件的Resources對(duì)象時(shí)會(huì)用到(設(shè)置之前是空的)
          • pm.getResourcesForApplication : 創(chuàng)建插件使用的Resources對(duì)象
          • RePlugin.getConfig().getCallbacks().createPluginClassLoader : 創(chuàng)建插件的ClassLoader
          • new PluginContext : 創(chuàng)建插件使用的 Context (PluginContext),
==2.2.3:總結(jié)==

可以看出來系統(tǒng)啟動(dòng)APP和我們動(dòng)態(tài)加載插件完全是不一樣的思路梁剔。

2.2 插件中使用其他插件的layout文件

通過如下調(diào)用鏈就可以獲取到具體的View

  • RePlugin.fetchViewByLayoutName
    • RePluginCompat.fetchViewByLayoutName
      • RePlugin.fetchContext : 加載插件虽画,并獲取插件自身的Context對(duì)象,以獲取資源等信息
      • RePluginCompat.fetchResourceIdByName : 要注意一下的是 在插件編譯期 這個(gè)方法最后調(diào)用的 Resources.getIdentifier 的第三個(gè)參數(shù)被替換成了 插件的包名荣病,至于為什么現(xiàn)在還不知道(==關(guān)注問題中的第八個(gè)码撰,會(huì)后會(huì)在哪里解答==)
        • RePlugin.fetchPackageInfo : 獲取 插件對(duì)應(yīng)的 PackageInfo
        • RePlugin.fetchResources : 加載插件,并獲取插件的資源信息
        • Resources.getIdentifier : 獲取資源id个盆,
      • LayoutInflater.from(context).inflate : 填充為View

3. 插件中的so文件是如何加載的脖岛?


3. RePlugin中的核心

  1. ClassLoader : DexClassLoader 和 RePluginClassLoader
  2. Context:PluginContext

問答

1. ==RePlugin是使用DexClassLoader加載自定義路徑下的dex嗎朵栖?==

答:是的,在Android中 DexClassLoader 總是動(dòng)態(tài)話的不二選擇柴梆,只不過 RePlugin中 有兩個(gè)ClassLoader 一個(gè)是宿主的 RePluginClassLoader 他是hook了App的原始 ClassLoader陨溅,一個(gè)是加載插件Class的 PluginDexClassLoader。當(dāng)宿主通過RePluginClassLoader加載一個(gè)插件里的類時(shí)绍在,它先會(huì)去使用插件的PluginDexClassLoader去加載门扇,如果找到了就直接返回,如果找不到才會(huì)去自己進(jìn)行加載揣苏。

至于為什么 DexClassLoader悯嗓,其實(shí)就是因?yàn)?DexClassLoader 在初始化的時(shí)候可以傳入一個(gè)已經(jīng)優(yōu)化過的dex文件路徑,就可以加載它卸察。 可以動(dòng)態(tài)化可以參考

2. ProcessPitProviderPersist這個(gè)provider對(duì)外提供binder然后進(jìn)行通信,這樣做不會(huì)有安全問題嗎铅祸?

3. replugin-host-lib中 manifest中 配置的爆紅的 四大組件是干嘛的坑质?

4. RePlugin.attachBaseContext方法中有提到 HostConfigHelper.init();需要在IPC.init只有進(jìn)行,那是不是說常駐進(jìn)程名肯定就是獨(dú)立進(jìn)程临梗?配置了也沒用涡扼?

答:有用的,不知道為啥會(huì)有那句注釋

5. PluginManagerProxy.connectToServer()是在干啥盟庞?

答:通過 binder 獲取到 PluginManagerServer.Stub 對(duì)象也就是 sRemote

6. StubProcessManager.schedulePluginProcessLoop這是在干啥吃沪?

答:應(yīng)該是在回收無用進(jìn)程

7. Plugin.attach 中的parent參數(shù)是已經(jīng)被 hook的 classLoader了嗎?

答:這個(gè)是沒有被 hook過的 什猖,因?yàn)檫@個(gè) 是在PmBase中初始化的票彪,PmBase這個(gè)類是在 hook之前加載的

8. ==replugin-plugin-gradle中為什么要替換 getIdentifier 的三個(gè)參數(shù)為當(dāng)前 插件包名?不替換行不行不狮?==

答:難道這個(gè)參數(shù)在解析.resc文件時(shí)會(huì)用到降铸,記得在ResGuard中就有解析packageName的時(shí)候,應(yīng)該是這樣的摇零,也不對(duì)啊推掸,它傳的的是調(diào)用方的包名...搞不懂

9. com.qihoo360.replugin.Entry 這個(gè)類在哪里?里面的 create 干了些啥驻仅?在Loader.loadEntryMethod3 方法中有使用到谅畅?

答:這個(gè)類位于 replugin-plugin-lib中,crate是宿主框架最先調(diào)用的類 用于初始化插件框架和環(huán)境

10. 動(dòng)態(tài)類是干啥的噪服? RePlugin.registerHookingClass 中會(huì)注冊(cè)毡泻?

答:在加載插件類的時(shí)候會(huì)用到 具體使用位置是 PmBase.loadClass,作用是作為真實(shí)類加載之前的 中介類,具體能干啥 還不太清楚芯咧,不過看描述很強(qiáng)大的感覺

11. ==PluginLibraryInternalProxy.startActivity 不是只是打開坑位Activity么牙捉?插件的Activity怎么顯示的竹揍?也就是Android 系統(tǒng)怎么被騙==了?

答:整體調(diào)用流程如下:

  • Pmbase根據(jù)Intent找到對(duì)應(yīng)的插件
  • 分配坑位Activity邪铲,與插件中的Activity建立一對(duì)一的關(guān)系并保存在PluginContainer中
  • 讓系統(tǒng)啟動(dòng)坑位Activity芬位,因?yàn)樗窃贛anifest中注冊(cè)過的
  • Android系統(tǒng)會(huì)嘗試使用RepluginClassLoader加載坑位Activity的Class對(duì)象
  • RepluginClassLoader 通過建立的對(duì)應(yīng)關(guān)系找到插件Activity,并使用PluginDexClassLoader 加載插件Activity 的Class對(duì)象并返回
  • Android系統(tǒng)就使用這個(gè)插件中的Activity的Class對(duì)象來運(yùn)行生命周期函數(shù)
  • 讓系統(tǒng)以為是自己的classLoader加載的類但是其實(shí)是使用插件ClassLoader加載的然后給到系統(tǒng)带到,這一招偷梁換柱 真的是高啊 昧碉。到此 Android系統(tǒng)就被 騙啦
    ==這樣貍貓換太子也太6了==
  • 參考:Replugin 全面解析 (2)

12. 常駐進(jìn)程什么時(shí)候啟動(dòng)的?

答:是在ui進(jìn)程啟動(dòng)的過程中 通過 PluginProcessMain.connectToHostSvc 這個(gè)方法觸發(fā) ProcessPitProviderPersist(運(yùn)行在常駐進(jìn)程)這個(gè)內(nèi)容提供者初始話而啟動(dòng)的

13. RePlugin是如何避免資源沖突的揽惹?

答:Replugin中宿主和插件被饿,插件和插件之間不會(huì)存在 資源沖突,因?yàn)?他們的資源壓根就不會(huì)合并搪搏。

14. data/data/包名/files 下的文件是什么時(shí)候復(fù)制過去的狭握?

15. p-n 插件 指的是啥?

16. V5插件是什么鬼疯溺?

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末论颅,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子囱嫩,更是在濱河造成了極大的恐慌恃疯,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,734評(píng)論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件墨闲,死亡現(xiàn)場(chǎng)離奇詭異今妄,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)鸳碧,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,931評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門盾鳞,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人杆兵,你說我怎么就攤上這事雁仲。” “怎么了琐脏?”我有些...
    開封第一講書人閱讀 164,133評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵攒砖,是天一觀的道長。 經(jīng)常有香客問我日裙,道長吹艇,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,532評(píng)論 1 293
  • 正文 為了忘掉前任昂拂,我火速辦了婚禮受神,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘格侯。我一直安慰自己鼻听,他們只是感情好财著,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,585評(píng)論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著撑碴,像睡著了一般撑教。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上醉拓,一...
    開封第一講書人閱讀 51,462評(píng)論 1 302
  • 那天伟姐,我揣著相機(jī)與錄音,去河邊找鬼亿卤。 笑死愤兵,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的排吴。 我是一名探鬼主播秆乳,決...
    沈念sama閱讀 40,262評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼傍念!你這毒婦竟也來了矫夷?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,153評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤憋槐,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后淑趾,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體阳仔,經(jīng)...
    沈念sama閱讀 45,587評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,792評(píng)論 3 336
  • 正文 我和宋清朗相戀三年扣泊,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了近范。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,919評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡评矩,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出阱飘,到底是詐尸還是另有隱情斥杜,我是刑警寧澤,帶...
    沈念sama閱讀 35,635評(píng)論 5 345
  • 正文 年R本政府宣布沥匈,位于F島的核電站蔗喂,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏高帖。R本人自食惡果不足惜缰儿,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,237評(píng)論 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望散址。 院中可真熱鬧乖阵,春花似錦宣赔、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,855評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至默终,卻和暖如春椅棺,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背齐蔽。 一陣腳步聲響...
    開封第一講書人閱讀 32,983評(píng)論 1 269
  • 我被黑心中介騙來泰國打工两疚, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人含滴。 一個(gè)月前我還...
    沈念sama閱讀 48,048評(píng)論 3 370
  • 正文 我出身青樓诱渤,卻偏偏與公主長得像,于是被迫代替她去往敵國和親谈况。 傳聞我的和親對(duì)象是個(gè)殘疾皇子勺美,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,864評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容