外掛化之程式碼呼叫與載入資源

juexingzhe發表於2018-11-08

最近一直在忙公司的業務,有兩個月時間沒有更新部落格了,感嘆堅持真是不容易。今天分享一下外掛化的一些預備知識點,外掛化是一個很大的話題,寫一本書也不一定能說完。整體就是跨APP去載入資源或者程式碼,在Android裡面尤其是載入四大元件,涉及到更多的姿勢。今天我們不涉及四大元件,主要是看下怎麼去跨APP呼叫程式碼或者載入資源。涉及到下面幾個知識點:

  1. gradle打包和移動apk
  2. 資源載入機制,包括resources/assets等
  3. 移動apk位置,會涉及到兩種io方式
  4. 構造DexClassLoader

寫了一個小Demo,後面外掛化的相關知識都會往這個Demo裡面去補充,先看看這次的實現效果。

1.實現效果

整個Demo裡面會有三個application工程,一個 library工程,佈局檔案很簡單,點選上面兩個按鈕,app主工程回去呼叫另外兩個工程下面的程式碼和載入對應的圖片資源。兩個按鈕下面有個TextView和`ImageView``,分別用來顯示呼叫程式碼返回的字串和載入得到的圖片。

demo1.jpg

2.gradle配置

先看下整個工程的目錄結構

demo2.png

先看看Demo裡面的多工程配置,主要是是兩類檔案, build.gradlesettings.gradle, plugin1和plugin2中的build.gradle基本是一樣的,就看plugin1下面的build.gradle,要編譯成apk需要使用Android的application外掛,一行程式碼

apply plugin: 'com.android.application'
複製程式碼

com這個目錄是要編譯成Android的library,需要載入library外掛

apply plugin: 'com.android.library'
複製程式碼

com這個Module下面是一個介面檔案,另外三個Module都依賴這個工程,在呼叫的時候就不用去通過反射拿到方法,方便舒爽。介面下就兩個api,一個呼叫程式碼獲取字串,一個拿到圖片資源。

public interface ICommon {
    String getString();

    int getDrawable();
}
複製程式碼

同時要配置工程根目錄下的settings.gradle檔案,這個目錄是告訴編譯時需要編譯哪幾個工程,

include ':app', ':plugin1', ':plugin2', ':com'
複製程式碼

上面就是專案多工程編譯需要注意的點。另外一個就是三個工程都依賴com庫

dependencies {
    ...
    implementation project(':com')
}
複製程式碼

接下來我們就需要編譯plugin1和plugin2兩個apk,最終需要再app中去載入這兩個apk檔案中的內容,所以我們在編譯後自動把這兩個apk移動到app的assets目錄下。在assemble這個task下面的doLast中去新增移動邏輯就行。

assemble.doLast {
    android.applicationVariants.all { variant ->
            println "onAssemble==="
        if (variant.name.contains("release") || variant.name.contains("debug")) {
            variant.outputs.each { output ->
                File originFile = output.outputFile
                println originFile.absolutePath
                copy {
                    from originFile
                    into "$rootDir/app/src/main/assets"
                    rename(originFile.name, "plugin1.apk")
                }
            }
        }
    }
}
複製程式碼

然後在命令列中通過gradle assemble完成編譯apk並移動的任務。

demo3.png

經過上面的步驟,兩個apk已經移動到app目錄下面的assets,並且分別命名為plugin1.apkplugin2.apk,接下來看看對apk的操作。

3.移動apk

在assets下的資源是不能通過路徑去直接操作的,必須通過AssetManager,所以我們把apk複製到包下面進行操作,這就涉及到io操作,有兩種方式可以,一種是okio,另外一種是傳統的Java IO。我們分別來看下這兩種方式的實現方式和耗時。

先看下okio的方式, okio的方式可以通過Okio.buffer的方式構造一個讀緩衝區,buffer有個最大值是64K,可以減少讀的次數。

        AssetManager assets = context.getAssets();
        InputStream inputStream = null;
        try {
            inputStream = assets.open(apkName);
            Source source = Okio.source(inputStream);
            BufferedSource buffer = Okio.buffer(source);
            Log.i(MainActivity.TAG, "" + context.getFileStreamPath(apkName));
            buffer.readAll(Okio.sink(context.getFileStreamPath(apkName)));
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
複製程式碼

看下用這種方式移動兩種apk的時間需要多久:

demo4.png

另外一種方式是傳統的io方式:

        AssetManager am = context.getAssets();
        InputStream is = null;
        FileOutputStream fos = null;
        try {
            is = am.open(apkName);
            File extractFile = context.getFileStreamPath(apkName);
            fos = new FileOutputStream(extractFile);
            byte[] buffer = new byte[1024];
            int count = 0;
            while ((count = is.read(buffer)) > 0) {
                fos.write(buffer, 0, count);
            }
            fos.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            closeSilently(is);
            closeSilently(fos);
        }
複製程式碼

看下耗時:

demo5.png

當然在傳統方式中把緩衝區改大一點時間上是會快一點,但是okio給我們提供了緩衝區的自動管理,更省心一點不用擔心oom,所以還是推薦用okio的方式。

上面的okio的截圖可以看出apk最終移動到包下面的files目錄。這裡說一個小知識點,通過run-as 包名就能看見兩個apk了。

adb shell
run-as com.example.juexingzhe.plugindemo
複製程式碼

現在已經有了兩個apk了,接下來就是通過操作來呼叫程式碼和資源了。

4.呼叫程式碼和資源

Android裡面說資源(除了程式碼)一般分為兩類,一類是在/res目錄,一類是在/assets目錄。/res目錄下的資源會在編譯的時候通過aapt工具在專案R類中生成對應的資源ID,通過resources.arsc檔案就能對映到對應資源,/res目錄下可以包括/drawable影象資源,/layout佈局資源,/mipmap啟動器圖示,/values字串顏色style等資源。而/assets目錄下會儲存原始檔名和檔案層次結構,以原始形式儲存任意檔案,但是這些檔案沒有資源ID,只能使用AssetManager讀取這些檔案。

平時在Activity中通過getResources().getXXX其實都會通過AssetManager去讀取,比如我們看下getText:

@NonNull public CharSequence getText(@StringRes int id) throws NotFoundException {
        CharSequence res = mResourcesImpl.getAssets().getResourceText(id);
        if (res != null) {
            return res;
        }
        throw new NotFoundException("String resource ID #0x"
                + Integer.toHexString(id));
    }
複製程式碼

看下getDrawable():

    public Drawable getDrawable(@DrawableRes int id) throws NotFoundException {
        final Drawable d = getDrawable(id, null);
        if (d != null && d.canApplyTheme()) {
            Log.w(TAG, "Drawable " + getResourceName(id) + " has unresolved theme "
                    + "attributes! Consider using Resources.getDrawable(int, Theme) or "
                    + "Context.getDrawable(int).", new RuntimeException());
        }
        return d;
    }

    public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
        final TypedValue value = obtainTempTypedValue();
        try {
            final ResourcesImpl impl = mResourcesImpl;
            impl.getValueForDensity(id, density, value, true);
            return impl.loadDrawable(this, value, id, density, theme);
        } finally {
            releaseTempTypedValue(value);
        }
    }

複製程式碼

ResourcesImpl中會通過loadDrawableForCookie載入, 如果不是xml型別就直接通過AssetManager載入,

/**
     * Loads a drawable from XML or resources stream.
     */
    private Drawable loadDrawableForCookie(@NonNull Resources wrapper, @NonNull TypedValue value,
            int id, int density, @Nullable Resources.Theme theme) {

            ...
            if (file.endsWith(".xml")) {
                final XmlResourceParser rp = loadXmlResourceParser(
                        file, id, value.assetCookie, "drawable");
                dr = Drawable.createFromXmlForDensity(wrapper, rp, density, theme);
                rp.close();
            } else {
                final InputStream is = mAssets.openNonAsset(
                        value.assetCookie, file, AssetManager.ACCESS_STREAMING);
                dr = Drawable.createFromResourceStream(wrapper, value, is, file, null);
                is.close();
            }
        } catch (Exception e) {
            ...
        }
      ...

        return dr;
    }
複製程式碼

如果是xml,會通過呼叫loadXmlResourceParser載入,可以看見最終還是AssetManager載入:

    @NonNull
    XmlResourceParser loadXmlResourceParser(@NonNull String file, @AnyRes int id, int assetCookie,
            @NonNull String type)
            throws NotFoundException {
        if (id != 0) {
            try {
                synchronized (mCachedXmlBlocks) {
                  ....
                    // Not in the cache, create a new block and put it at
                    // the next slot in the cache.
                    final XmlBlock block = mAssets.openXmlBlockAsset(assetCookie, file);
                    if (block != null) {
                        ...
                }
            } catch (Exception e) {
                final NotFoundException rnf = new NotFoundException("File " + file
                        + " from xml type " + type + " resource ID #0x" + Integer.toHexString(id));
                rnf.initCause(e);
                throw rnf;
            }
        }

        throw new NotFoundException("File " + file + " from xml type " + type + " resource ID #0x"
                + Integer.toHexString(id));
    }
複製程式碼

上面簡單說了下Android中資源的型別和它們的關係,所以我們如果要載入外掛中的資源,關鍵就是AssetManager,而AssetManager載入資源其實是通過addAssetPath來新增資源路徑,然後就能載入到對應資源。

    /**
     * Add an additional set of assets to the asset manager.  This can be
     * either a directory or ZIP file.  Not for use by applications.  Returns
     * the cookie of the added asset, or 0 on failure.
     * {@hide}
     */
    public final int addAssetPath(String path) {
        return  addAssetPathInternal(path, false);
    }
複製程式碼

所以我們就可以把外掛apk的路徑新增到addAssetPath中,然後再構造對應的Resources,那麼就可以拿到外掛裡面res目錄下的資源了。而系統addAssetPath是不對外開放的,我們只能通過反射拿到。

有了上面思路,程式碼實現就簡單了,在Demo裡面點選按鈕的時候去通過反射拿到addAssetPath,然後把外掛apk的路徑傳給它,然後構造一個新的AssetManager,和新的Resources.

    public static void addAssetPath(Context context, String apkName) {
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager, pluginInfos.get(apkName).getDexPath());

            sAssetManager = assetManager;
            sResources = new Resources(assetManager,
                    context.getResources().getDisplayMetrics(),
                    context.getResources().getConfiguration());
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }

    }
複製程式碼

然後在Activity中重寫介面,返回新的AssetManagerResources:

    @Override
    public AssetManager getAssets() {
        return AssetUtils.sAssetManager == null ? super.getAssets() : AssetUtils.sAssetManager;
    }

    @Override
    public Resources getResources() {
        return AssetUtils.sResources == null ? super.getResources() : AssetUtils.sResources;
    }
複製程式碼

最後奉上一段英文解釋/res和/assets區別的:

Resources are an integral part of an Android application. In general, these are 
external elements that you want to include and reference within your application, 
like images, audio, video, text strings, layouts, themes, etc. Every Android 
application contains a directory for resources (`res/`) and a directory for 
assets (`assets/`). Assets are used less often, because their applications are far
 fewer. You only need to save data as an asset when you need to read the raw bytes. 
The directories for resources and assets both reside at the top of an Android 
project tree, at the same level as your source code directory (`src/`).

The difference between "resources" and "assets" isn't much on the surface, but in 
general, you'll use resources to store your external content much more often than 
you'll use assets. The real difference is that anything placed in the resources 
directory will be easily accessible from your application from the `R` class, which 
is compiled by Android. Whereas, anything placed in the assets directory will 
maintain its raw file format and, in order to read it, you must use the [AssetManager]
(https://developer.android.com/reference/android/content/res/AssetManager.html) to 
read the file as a stream of bytes. So keeping files and data in resources (`res/`) 
makes them easily accessible.

複製程式碼

現在就差最後一步,就是通過自定義ClassLoader去載入外掛apk中的ICommon的實現類,然後呼叫方法獲取字串和影象。

5.構造ClassLoader

我們都知道Java能跨平臺執行關鍵就在虛擬機器,而虛擬機器能識別的檔案是class檔案,Android的虛擬機器DalvikART則對class檔案進行優化,它們載入的是dex檔案。

Android系統中有兩個類載入器分別為PathClassLoaderDexclassLoader,PathClassLoaderDexClassLoader都是繼承與BaseDexClassLoaderBaseDexClassLoader繼承於ClassLoader,看下Android 8.0裡面的ClassLoaderloadClass方法:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            return c;
    }
複製程式碼

上面就是Java裡面的雙親委託機制,載入一個類都會先通過parent.loadClass,最終找到BootstrapClassLoader,如果還是沒找到,會通過 findClass(name)去查詢,這個就是我們自定義classLoader需要自己實現的方法。

但是在Android 8.0系統裡面,

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
複製程式碼

這是因為Android的基類BaseDexClassLoader實現了findClass去載入指定的class。Android系統預設的類載入器是它的子類PathClassLoaderPathClassLoader只能載入系統中已經安裝過的apk,而DexClassLoader能夠載入自定義的jar/apk/dex。

BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent)
複製程式碼

二者建構函式差不多,區別就是一個引數optimizedDirectory,這個是指定dex優化後的odex檔案,PathClassLoaderoptimizedDirectory為null,DexClassLoader中為new File(optimizedDirectory)PathClassLoader在app安裝的時候會有一個預設的優化odex的路徑/data/dalvik-cache,DexClassLoader的dex輸出路徑為自己輸入的optimizedDirectory路徑。

所以我們需要去構造一個DexClassLoader來載入外掛的程式碼。先抽出一個bean來儲存關鍵的資訊,一個就是apk的路徑,另外一個就是自定義的DexClassLoader:

/**
 * 外掛包資訊
 */
public class PluginInfo {
    private String dexPath;
    private DexClassLoader classLoader;

    public PluginInfo(String dexPath, DexClassLoader classLoader) {
        this.dexPath = dexPath;
        this.classLoader = classLoader;
    }

    public String getDexPath() {
        return dexPath;
    }

    public DexClassLoader getClassLoader() {
        return classLoader;
    }
}
複製程式碼

再接著看下構造DexClassLoader的方法:

    /**
     * 構造apk對應的classLoader
     *
     * @param context
     * @param apkName
     */
    public static void extractInfo(Context context, String apkName) {
        File apkPath = context.getFileStreamPath(apkName);
        DexClassLoader dexClassLoader = new DexClassLoader(
                apkPath.getAbsolutePath(),
                context.getDir("dex", Context.MODE_PRIVATE).getAbsolutePath(),
                null,
                context.getClassLoader());
        PluginInfo pluginInfo = new PluginInfo(apkPath.getAbsolutePath(), dexClassLoader);
        pluginInfos.put(apkName, pluginInfo);
    }
複製程式碼

先看下apk1裡面的介面程式碼:

package com.example.juexingzhe.plugin1;


import com.example.juexingzhe.com.ICommon;

public class PluginResources implements ICommon {
    @Override
    public String getString() {
        return "plugin1";
    }

    @Override
    public int getDrawable() {
        return R.drawable.bg_1;
    }
}
複製程式碼

很簡單,就是實現com包下的ICommon介面,接著看下點選按鈕時候怎麼去呼叫程式碼和拿到資源的。

        btn1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                PluginInfo pluginInfo = AssetUtils.getPluginInfo(APK_1);
                AssetUtils.addAssetPath(getBaseContext(), APK_1);

                DexClassLoader classLoader = pluginInfo.getClassLoader();
                try {
                    Class PluginResources = classLoader.loadClass("com.example.juexingzhe.plugin1.PluginResources");
                    ICommon pluginObject = (ICommon) PluginResources.newInstance();
                    textView.setText(pluginObject.getString());
                    imageView.setImageResource(pluginObject.getDrawable());
                } catch (ClassNotFoundException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                } catch (InstantiationException e) {
                    e.printStackTrace();
                }

            }
        });
複製程式碼
  1. 首先呼叫addAssetPath構造AssertManagerResources
  2. 從pluginInfo中拿到DexClassLoader,pluginInfo是在onCreate中賦值的
  3. 通過上面DexClassLoader載入apk1中的介面com.example.juexingzhe.plugin1.PluginResources
  4. 將上面Class構造例項並強轉為介面ICommon,這樣就可以直接呼叫方法,不用反射呼叫
  5. 呼叫方法獲得字串和影象資源

6.總結

簡單總結下,上面通過構造AssetManagerResources去載入外掛apk中的資源,當然程式碼呼叫需要通過DexClassLoader,這個也需要自己去構造,才能載入指定路徑的apk程式碼。還簡單介紹了下gradle打包和複製的功能,資源載入,雙親委託機制,IO的兩種方式等。

本文結束。

歡迎大家關注哈。

相關文章