插件化之代碼調用與加載資源

本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家發布

最近一直在忙公司的業務,有兩個月時間沒有更新博客了,感嘆堅持真是不容易。今天分享一下插件化的一些預備知識點,插件化是一個很大的話題,寫一本書也不一定能說完。整體就是跨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的兩種方式等。

本文結束。

歡迎大家關注哈。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容