編輯:關於Android編程
這段時間比較難閒,就抽空研究一下Android熱修復的原理。自從Android熱修復這項技術出現之後,隨之而現的是多種熱修復方案的出現。前兩天又看到一篇文章分析了幾種熱修復方案的比較。
看完這篇文章,有點汗顏。有這麼多的熱修復方案,並且他們之間的實現原理也不一樣,各有優缺點。
本文對於這些實踐做出一點總結。本文有些代碼片段、圖片來自上述文章。
首先列出熱修復需要解決的幾個問題:
資源替換 類替換(四大組件、類) SO補丁基於上面3個問題,我做了幾個測試,分別是動態加載資源、和動態運行APK中的Activity。至於SO補丁方面的,由於本人技術有限,沒有研究。
在Android中有兩個類加載器,分別為PathClassLoader和DexClassLoader。其中我們正常開發的APP使用的類加載器就是PathClassLoader。
關於這兩個類在代碼中的實際使用:
PathClassLoader:通過Context getClassLoader() 獲取。 DexClassLoader:通過構造函數 new DexClassLoader()獲取。DexClassLoader的構造函數原型是:
public DexClassLoader(String dexPath, String dexOutputDir, String libPath, ClassLoader parent)dexPath: 表示加載的APK/dex/jar路徑 dexOutOutDir: 解壓文件的路徑,因為APK和JAR最終都要解壓出dex文件,這個路徑是用來存放dex文件的。 libPath:加載的時候用到的lib庫,一般為null parent:DexClassLoader的父加載器
目標:加載另一個APK中的資源文件
思路:Andorid APP默認的類加載是PathClassLoader,這個只能加載自己APK的dex文件,所以我們需要使用DexClassLoader。我們用DexClassLoader加載外部的APK之後,通過反射獲取對應的資源。
項目分為2個工程,一個宿主工程,一個插件工程。
首先我們看插件工程:
public class UIUtil { public static String getTextString(Context ctx){ return ctx.getResources().getString(R.string.text); } public static Drawable getImageDrawable(Context ctx){ return ctx.getResources().getDrawable(R.mipmap.ic_launcher); } public static int getTextBackgroundId(Context ctx){ return ctx.getResources().getColor(R.color.color_green); } }
插件工程中有一個UIUtil類,提供了幾個靜態的方法分別用來獲取對應的資源(文字,圖標,顏色)
接下來我們看看宿主工程中如何加載這裡的資源。
首先我們需要創建一個DexClassLoaderDexClassLoader classLoader = new DexClassLoader(filePath, fileRelease, null, getClassLoader());
filePath指的是插件APK的文件路徑,注意:這裡需要放在/data/data/packagename/中才能生效。因為Android系統的限制,自己加載的dex只能在程序獨有的文件中存在。
這裡代碼最後一個參數傳進來的是getClassLoader(),實際上就是PathClassLoader,那麼為什麼需要把這個PathClassLoader作為DexClassLoader的父加載器呢。這裡的ClassLoader符合Java類加載器的雙親委派機制
通過反射調用APK中的方法。
Class clazz = null; try { clazz = classLoader.loadClass("com.example.resourceloaderapk.UIUtil"); //設置文字 Method method = clazz.getMethod("getTextString", Context.class); String str = (String) method.invoke(null, this); textV.setText(str); //設置背景 method = clazz.getMethod("getTextBackgroundId", Context.class); int color = (int) method.invoke(null, this); Log.i("Loader", "color = " + color); textV.setBackgroundColor(color); //設置圖片 method = clazz.getMethod("getImageDrawable", Context.class); Drawable drawable = (Drawable) method.invoke(null, this); Log.i("Loader", "drawable =" + drawable); imgV.setImageDrawable(drawable); } catch (Exception e) { e.printStackTrace(); }
運行流程是這樣的,首先我們把插件APK打包之後,拷貝到/data/data/宿主apk packagename/ 中的任意目錄。然後上面初始化DexClassLoader的時候把這個路徑傳過去。從代碼可以看到,首先加載UIUtil類,然後調用它的幾個方法來獲取對應的資源。
這樣運行之後發現文字是可以獲取的,但是image和color是無法獲取到,會拋出Resources$NotFoundException,資源找不到異常。我們知道,APK中所有的資源都是通過Resources來獲取的。看回上面的代碼,是通過傳遞一個this關鍵字把宿主的Context對象傳遞過去的。這個Context對象是宿主工程的Context,它並不能訪問插件APK的資源,那麼我們需要做的就是把插件APK的資源加載到宿主Context中對應的Resources對象中。
這裡使用的方法是調用AssetManager的addAssetPath()方法,將一個APK中的資源加載到Resources中,這個方法是隱藏的,我們通過反射獲取,如下:
/** * 此方法的作用是把resource.apk中的資源加載到AssetManager中, * 然後在重組一個Resources對象,這個Resources對象包括了resource.apk中的資源。 *
* resource.apk 中是使用Context.getResources()獲得Resource對象的, * 所以還要重寫一些getResources()方法,返回該Resources對象 * * @param dexPath */ protected void loadResource(String dexPath) { try { AssetManager assetManager = AssetManager.class.newInstance(); Method method = assetManager.getClass().getMethod("addAssetPath", String.class); method.invoke(assetManager, dexPath); mAssetManager = assetManager; } catch (Exception e) { e.printStackTrace(); } Resources resource = getResources(); mResources = new Resources(mAssetManager, resource.getDisplayMetrics(), resource.getConfiguration()); mTheme = mResources.newTheme(); mTheme.setTo(getTheme()); }
這樣處理之後我們就重新生成了一個Resource對象,AssetManger對象和Theme對象。我們需要重寫下面3個方法達到替換的目的。
@Override public AssetManager getAssets() { return mAssetManager == null ? super.getAssets() : mAssetManager; } @Override public Resources getResources() { return mResources == null ? super.getResources() : mResources; } @Override public Resources.Theme getTheme() { return mTheme == null ? super.getTheme() : mTheme; }
這樣getResources獲取到的就是我們新生成的mResources對象了,這個對象包括了原有的資源和插件APK的資源。
至此,加載插件APK中的資源就實現了功能。
這裡僅僅是對動態加載一些R文件的引用成功了,但是還有很多問題沒有深入的去解決,比如相同包名的情況下該如何處理?接口如何統一?資源和原有的資源同名怎麼處理?這麼多問題的存在都需要大量的時間來解決,這裡只是給出一個思路,猶如管中窺豹,由此對於資源的加載有一個感性的基礎認識。
由上面的描述我們知道,一個應用的默認類加載器是PathClassLoader,我們加載插件的時候使用的是DexClassLoader。雖然我們可以用DexClassLoader來獲取到Activity的實例,但是我們不能僅僅new一個Intent對象然後啟動Activity,因為我們從DexClassLoader中加載的Activity類僅僅是一個普通的JAVA類,Android四大組件都有自己的啟動流程和生命周期,使用DexClassLoader不會涉及到任何生命流程的東西。
既然這樣,那麼就要從Activity的啟動流程入手了。我們需要做的不是詳細了解Activity的啟動流程,思路是將加載了dex的DexClassLoader綁定到系統啟動Activity的類加載器上就行了。
了解過一點Andorid源碼的小伙伴應該都知道,我們Activity的啟動流程涉及到ActivityThread類,我們來看看它的源碼:
在裡面有一個靜態的sCurrentActivityThread對象,我們暫且不管他是如何創建實例的,因為應用啟動的時候就會啟動一個Activity,這時候sCurrentActivityThread對象肯定不為空。我們獲取到ActivityThread對象之後,我們再看看代碼,裡面有一個mPackages保存的是以packageName為key,LoadedApk為value的map。
再點開LoadedApk來觀察:
裡面有個mClassLoader對象。好了,好嗨森,我們只需要把自己的DexClassLoader設置到這個mClassLoader對象就能正常啟動Activity了
代碼如下:
public void replaceLoadedApk(View v) { try { //通過替換LoadedApk中的mClassLoader來達到加載apk中的Activity String fileDir = getCacheDir().getAbsolutePath(); String filePath = fileDir + File.separator + Constants.RESOURCE_APK_NAME; //源dex/jar/apk 目錄 DexClassLoader loader = new DexClassLoader(filePath, getDir("dex", MODE_PRIVATE).getAbsolutePath(), null, getClassLoader()); Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread", new Class[]{}, new Object[]{}); String packageName = getPackageName(); //通過反射獲取ActivityThread的 mPackages 對象 ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mPackages"); //通過反射獲mPackages獲得當前的LoadedApk對象 WeakReference wr = (WeakReference) mPackages.get(packageName); Log.i(TAG, "wr = " + wr.get()); //替換LoadedApk中的mClassLoader 為我們自己的DexClassLoader RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader", wr.get(), loader); Log.i(TAG, "classloader = " + loader); startResourceActivity(filePath, loader); } catch (Exception e) { Log.i(TAG, "load apk error :" + Log.getStackTraceString(e)); } } /** * 啟動插件Activity * @param filePath * @param loader * @throws ClassNotFoundException * @throws NoSuchFieldException * @throws IllegalAccessException * @throws NoSuchMethodException * @throws InvocationTargetException */ private void startResourceActivity(String filePath, ClassLoader loader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { //加載資源 loadResources(filePath); //加載Activity ,確保這裡的類名和Constants.RESOURCE_APK_NAME 中的 類名相同 Class clazz = loader.loadClass("com.example.resourceloaderapk.MainActivity"); //找到R.layout.activity_main Class rClazz = loader.loadClass("com.example.resourceloaderapk.R$layout"); Field field = rClazz.getField("activity_main"); Integer ojb = (Integer)field.get(null); View view = LayoutInflater.from(this).inflate(ojb, null); //設置靜態變量。這裡為什麼要設置靜態變量呢。 // 因為測試發現setContentView() 沒有起作用。 // 所以在啟動Activity之前保存一個靜態的View,設置到Activity中 Method method = clazz.getMethod("setLayoutView", View.class); method.invoke(null, view); //找到MainActivity,然後啟動 startActivity(new Intent(this, clazz)); }
噢,忘了說,在插件工程中創建一個MainActivity,包名為com.example.resourceloaderapk,裡面給各個聲明周期打一下log
public class MainActivity extends AppCompatActivity { public static final String TAG = "Resource_MainActivity"; private static View parentView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if(parentView == null){ setContentView(R.layout.activity_main); }else{ setContentView(parentView); } } public static void setLayoutView(View view){ parentView = view; } @Override protected void onResume() { super.onResume(); Log.i(TAG, "resource activity onResume"); } @Override protected void onStart() { super.onStart(); Log.i(TAG, "resource activity onStart"); } @Override protected void onStop() { super.onStop(); Log.i(TAG, "resource activity onStop"); } @Override protected void onPause() { super.onPause(); Log.i(TAG, "resource activity onPause"); } @Override protected void onDestroy() { super.onDestroy(); Log.i(TAG, "resource activity onDestroy"); }
這裡增加了一個靜態方法設置一個View,然後在onCreate中優先加載這個View。具體原因是因為在實踐過程中,發現setContentView(layoutId)並不生效,所以先生成一個View在加載頁面了。
在這裡的過程中,在運行的時候發現會拋出一個熟悉的錯誤:Unable to find explicit activity class. have you decleared this activity in the AndroidManifest.xml?
沒理由呀,在插件工程中已經聲明了的,但是想想還是能理解,我們在宿主Activity中`startActivity,所以需要在宿主工程中聲明這個組件。
這裡的方法比較貼近QQ空間提出的替換dex的方案。PathClassLoader和DexClassLoader都是屬於BaseDexClassLoader的子類。
然後BaseDexClassLoader中一個成員為DexPath pathList:
再看看DexPathList:
裡面有個Element數組,這個數組是用來存放dex文件的路徑的,系統默認的類加載器是PathClassLoader,程序加載之後會釋放出一個dex文件,那麼我們的做法就是,把DexClassLoader的dexElements和PathClassLoader的dexElements文件合並之後再放到PathClassLoader的pathList中。這樣Activity的啟動流程也是正確的。
如下:
public void injectDexElements(View v){ Log.i(TAG,"this classloader = " + getClassLoader()); PathClassLoader pathClassLoader = (PathClassLoader) getClassLoader(); String fileDir = getCacheDir().getAbsolutePath(); String filePath = fileDir + File.separator + Constants.RESOURCE_APK_NAME; //源dex/jar/apk 目錄 DexClassLoader loader = new DexClassLoader(filePath, getDir("dex", MODE_PRIVATE).getAbsolutePath(), null, getClassLoader()); try { //把PathClassLoader和DexClassLoader的pathList對象中的 dexElements 合並 Object dexElements = combineArray( getDexElements(getPathList(pathClassLoader)), getDexElements(getPathList(loader))); //把合並後的dexElements設置到PathClassLoader的 pathList對象中的 dexElements Object pathList = getPathList(pathClassLoader); setField(pathList, pathList.getClass(), "dexElements", dexElements); startResourceActivity(filePath,pathClassLoader); } catch (IllegalArgumentException e) { e.printStackTrace(); } catch (NoSuchFieldException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (NoSuchMethodException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } } private static Object getPathList(Object baseDexClassLoader) throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException { ClassLoader bc = (ClassLoader)baseDexClassLoader; return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList"); } private static Object getField(Object obj, Class cl, String field) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException { Field localField = cl.getDeclaredField(field); localField.setAccessible(true); return localField.get(obj); } private static Object getDexElements(Object paramObject) throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException { return getField(paramObject, paramObject.getClass(), "dexElements"); } private static void setField(Object obj, Class cl, String field, Object value) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException { Field localField = cl.getDeclaredField(field); localField.setAccessible(true); localField.set(obj, value); } private static Object combineArray(Object arrayLhs, Object arrayRhs) { Class localClass = arrayLhs.getClass().getComponentType(); int i = Array.getLength(arrayLhs); int j = i + Array.getLength(arrayRhs); Object result = Array.newInstance(localClass, j); for (int k = 0; k < j; ++k) { if (k < i) { Array.set(result, k, Array.get(arrayLhs, k)); } else { Array.set(result, k, Array.get(arrayRhs, k - i)); } } return result; }
運行的結果是一樣的。
還有一種方式就和前兩種方式的思路截然不同了。這裡的思路是在宿主工程中創建一個代理Activity,然後插件Apk中的Activity就僅僅是一個普通的java類,對應著幾個聲明周期方法,然後通過反射在代理Activity的生命周期方法中調用對應的插件Activity的方法。我這裡沒有實踐過,但是理論上是一種不錯的方案。
還是那句話,熱修復的坑很多,這裡的知識僅僅是冰山一角,還有很多問題需要解決,但是這樣折騰一下,起碼不會對熱修復這東西兩眼懵逼了。
源碼地址
簡單的日歷實現,只是顯示了每一個月,沒有顯示當天和記事這些功能主要是計算月初是周幾,月末是周幾,然後相應的顯示上一月多少天和下一月多少天。先看一下關於日期的用到的幾個工具
應用程序中可以查看應用程序的相關信息,其中有一個功能是清除緩存。如圖: 怎麼實現這些功能呢,從Android的setting源碼中可以得到相關信息。 實現如下:
自定義View通訊錄字母快速索引在Android日常開發中,我們經常在聯系人界面看到一些字母導航欄,點擊字母的時候,會根據漢字的首拼音來查找是否存在相應的item,這種效
android studio升級到stable 2.2之後,發現還有了個ConstraintLayout。看名字就是約束布局,用各種約束來確定widget的展示。該Con