Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> 基於Fragment的輕量級Android插件化框架

基於Fragment的輕量級Android插件化框架

編輯:關於Android編程

概述

Android插件化顧名思義,就是把APP分成N多插件,可以隨意對插件進行熱插拔。插件化帶來的好處是,減小了軟件耦合,同時開發人員可以模塊開發,提高了開發效率,而且線上bug可以通過升級插件方式快速修復。

一般情況下Android的插件就是一個單獨的apk,開發模式與Android原生應用沒有太大區別。我們要解決的問題是,如何在不安裝的情況下把這個apk運行起來。apk內主要包含class.dex和相關資源文件,class.dex內部是Android虛擬機字節碼。所以要想APP能運行,需要載入Android虛擬機字節碼與插件apk中的資源。

本文寫了一個簡單的插件化框架,下載地址https://github.com/pengyuntao/yuntao-plugin
本插件使用fragment來構建頁面,沒有實現service,receiver,provider等的動態加載,這裡只是作為學習的例子,當然純界面應用也可以使用這種架構來分模塊開發,動態升級替換模塊。

該例子代碼不多,閱讀下文請對照例子。核心類只有PluginInstallUtils,PluginHostActivity兩個類。host工程是宿主工程,plugin1,plugin2,plugin3是插件工程,pluginlib是依賴庫工程。運行時候請將三個插件工程打包成apk放在sdcard/yuntao-plugin目錄下,沒有請創建該目錄。

類加載

參考PluginInstallUtils類

要想實現動態加載,第一步需要實現加載插件中的類。JAVA提供了類加載器來加載jar中的類。但是Android識別的是dex文件不同與class文件,所以不能使用JAVA原生的類加載器,還好Android提供了用於加載dex中類的類加載器,DexClassLoader,PathClassLoader。

這兩者的區別在於DexClassLoader需要提供一個可寫的outpath路徑,用來釋放.apk包或者.jar包中的dex文件。換個說法來說,就是PathClassLoader不能主動從zip包中釋放出dex,因此只支持直接操作dex格式文件,或者已經安裝的apk(因為已經安裝的apk在cache中存在緩存的dex文件)。而DexClassLoader可以支持.apk、.jar和.dex文件,並且會在指定的outpath路徑釋放出dex文件。

看DexClassLoader構造方法的幾個參數
String dexPath 要加載的apk的絕對路徑
String optimizedDirectory apk解壓出來的目錄
String libraryPath native lib的路徑,可以為null
ClassLoader parent 父類加載器

所以我們可以使用以下代碼方式來加載一個apk中的類到內存中。

private DexClassLoader createDexClassLoader(Context context,String dexPath){?
    File dexOutputDir = mContext.getDir("dex", Context.MODE_PRIVATE);
    dexOutputPath = dexOutputDir.getAbsolutePath();
    DexClassLoader loader = new DexClassLoader(dexPath, dexOutputPath, null, context.getClassLoader());
    return loader;
}

資源加載

參考PluginInstallUtils類

資源加載的方法是調用AssetManager中的addAssetPath方法,我們可以將一個apk中的資源加載到Resources中,由於addAssetPath是隱藏api我們無法直接調用,所以只能通過反射,通過注釋我們可以看出,傳遞的路徑可以是zip文件也可以是一個資源目錄,而apk就是一個zip,所以直接將apk的路徑傳給它,資源就加載到AssetManager中了,然後再通過AssetManager來創建一個新的Resources對象,這個對象就是我們可以使用的apk中的資源了,這樣我們的問題就解決了。

    /**
     * 創建AssetManager對象
     *
     * @param dexPath apk路徑
     * @return
     */
    private AssetManager createAssetManager(String dexPath) {
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager, dexPath);
            return assetManager;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 創建Resource對象
     *
     * @param assetManager 上邊方法創建的assetManager
     * @return
     */
    private Resources createResources(AssetManager assetManager) {
        Resources superRes = mContext.getResources();
        Resources resources = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
        return resources;
    }

如何讓插件環境載入APP上下文

參考PluginHostActivity類

類與資源已經載入到內存中了,那麼如何使用他們呢。

由於我們的界面使用的是fragment,然而fragment必須附加到一個Activity上,因此fragment使用的資源,ClassLoader等都是與附加的Activity相同的。Activity是繼承自Context,Context涉及到資源與類加載的有下列三個抽象方法,我們只要實現下列方法就可以了,讓下列方法返回上兩節中創建的插件的AssetManager,Resources,ClassLoader。

/** Return an AssetManager instance for your application's package. */
public abstract AssetManager getAssets();

/** Return a Resources instance for your application's package. */
public abstract Resources getResources();

/** Return a class loader you can use to retrieve classes in this package.*/
public abstract ClassLoader getClassLoader();

由於我們做的是界面相關的還要再實現一個Theme的方法

/** Return the Theme object associated with this Context.*/
public abstract Resources.Theme getTheme();

最後我們還是要提供一個Activity當做宿主,我們重寫這個Activity的上述四個方法(如果了解Context的架構可以修改ContextImpl也可以,我們直接修改Activity的滿足要求了,就沒必要去改ContextImpl了)。當然這個時候通過反射加載類並調用方法,有興趣可以往類中隨便寫個方法打log測試下。

使用fragment構建頁面

參考PluginHostActivity類

由於大多數APP都是由一個個頁面組成的,使用Activity來構建頁面需要在mainifest文件中注冊Activity,APP在安裝之初內部的Activity就固定了,不能動態任意添加刪除Activity,同時Activity還需要管理生命周期,比較復雜(當然有好多插件框架實現了動態加載任意四大組件,例如DroidPlugin,DL等,有興趣可以自己去了解相關技術),這裡為了簡單我們使用fragment來創建界面,fragment沒有mainifest的限制,同時fragment由FragmentManager管理,可以任意的創建銷毀,所以我們可以做一個純fragment的應用。

    /**
     * 反射創建fragment,通過fragmentManager把創建的fragment附加到Activity
     * @param fragClass
     */
    protected void installPluginFragment(String fragClass) {
        try {
            if (isFinishing()) {
                return;
            }
            ClassLoader classLoader = getClassLoader();
            Fragment fg = (Fragment) classLoader.loadClass(fragClass).newInstance();
            Bundle bundle = getIntent().getExtras();
            fg.setArguments(bundle);
            getSupportFragmentManager().beginTransaction()
                    .replace(android.R.id.primary, fg).commitAllowingStateLoss();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

上述代碼通過fragment類全名打開一個fragment,添加到PluginHostActivity中,詳情參考PluginHostActivity

fragment之間跳轉

參考BaseFragment,PluginHostActivity類

我們設置Activity啟動模式為standard模式,每次打開一個Activity實例附加一個fragment,通過Intent把fragment類名傳遞給Activity,讓Activity去反射創建fragment並且加載他,可以BaseFragment裡封裝一個startFragment方法用來打開頁面

加載多個插件

參考PluginInstallUtils,PluginEnv類

加載插件的過程還是很耗時的,所以我們可以通過一個Map把插件加載的數據緩存起來,下次遇到相同的插件就直接取出。

public final static HashMap mPackagesHolder = new HashMap();

這裡使用apkPath作為key,當然這個key只要是唯一的就行,比如可以定義插件id之類的,總之就是為了不重復加載,下次可以根據這個標志能拿到緩存的數據即可。使用插件id也可以達到通過服務器下發插件id來加載插件的目的。value裡存儲的就是一些插件相關環境,Resource,ClassLoader等

/**
 * 插件的運行環境
 */
public class PluginEnv {

    public ClassLoader pluginClassLoader;
    public Resources pluginRes;
    public AssetManager pluginAsset;
    public Resources.Theme pluginTheme;
    public String localPath;

    public PluginEnv(String localPath, ClassLoader pluginClassLoader, Resources pluginRes, AssetManager pluginAsset, Resources.Theme pluginTheme) {
        this.pluginClassLoader = pluginClassLoader;
        this.pluginRes = pluginRes;
        this.pluginAsset = pluginAsset;
        this.pluginTheme = pluginTheme;
        this.localPath = localPath;
    }
}

類重復加載導致的類沖突異常問題

參考PluginClassLoader,PluginInstallUtils類

加載多個插件的時候會遇到一個問題,就是當多個插件都引用一個lib,該lib內的類會被加載多次,這個時候使用這些類的時候,就會發生錯誤。

兩種解決方案:讓依賴包只參與編譯,不打入最終的包內,這個好處是能讓插件包小一些;重寫類加載器,Android的類加載器是支持雙親委派的,可以保證宿主加載lib的類一份就行了。我們這裡使用了第二種重寫類加載器的方式。

public class PluginClassLoader extends DexClassLoader {
    public PluginClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
        super(dexPath, optimizedDirectory, libraryPath, parent);
    }

    @Override
    protected Class loadClass(String className, boolean resolve) throws ClassNotFoundException {
        //如果vm已經加載了,返回該類,否則返回null
        Class clazz = findLoadedClass(className);
        if (clazz == null) {
            //如果vm沒有加載讓該類的父加載器加載
            try {
                clazz = this.getParent().loadClass(className);
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            }
            if (clazz == null) {
                //當前加載器加載
                clazz = findClass(className);
            }
        }
        return clazz;
    }
}

插件之間跳轉

參考PluginHostActivity

其實PluginHostActivity是在宿主中注冊的,插件不依賴宿主,所以不能直接顯示的startActivity,我們可以通過action方式來讓宿主的PluginHostActivity來加載其他插件的fragment。

LayoutInflate加載布局文件自定義控件導致類轉換錯誤問題

參考WidgetLayoutInflaterFactory,PluginHostActivity類

在使用插件升級的時候,使用新的插件替換了舊插件,當layout布局文件中寫了自定義控件的時候,打開該頁面通常會拋出以下異常。

......

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.yuntao.host/com.yuntao.pluginlib.PluginHostActivity}: java.lang.ClassCastException: com.yuntao.plugin3.CustomTextView cannot be cast to com.yuntao.plugin3.CustomTextView

......

Caused by: java.lang.ClassCastException: com.yuntao.plugin3.CustomTextView cannot be cast to com.yuntao.plugin3.CustomTextView

......

在LayoutInflate內部有個靜態的Map保存了曾經解析出的View的構造函數,key為控件的name,所以在升級插件之後,由於不同的插件使用了不同的類加載器,類加載器變了,在解析View的時候首先根據name去map中取,會返回舊插件的View。然而當前插件與上個版本插件的ClassLoader不一樣,所以會出現類轉換錯誤。
研究下源碼,下邊代碼為緩存的數據結構

private static final HashMap> sConstructorMap =
            new HashMap>();

查看創建view的過程,就是根據view的name,把constructor緩存了起來,存在就直接實例化了,不存在才創建

    public final View createView(String name, String prefix, AttributeSet attrs{//略去throws
        Constructor constructor = sConstructorMap.get(name);
        Class clazz = null;
        //略去try catch
        if (constructor == null) {
            clazz = mContext.getClassLoader().loadClass(?prefix != null ? (prefix + name) : name).asSubclass(View.class);
            //此處略去幾行
            constructor = clazz.getConstructor(mConstructorSignature);
            constructor.setAccessible(true);
            sConstructorMap.put(name, constructor);
        } else {
            //此處略去n多行
        }
        Object[] args = mConstructorArgs;
        args[1] = attrs;
        final View view = constructor.newInstance(args);
        if (view instanceof ViewStub) {
            // Use the same context when inflating ViewStub later.
            final ViewStub viewStub = (ViewStub) view;
            viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
        }
        return view;
    }

查看調用createView的代碼片段,調用之前會執行幾個factory的onCreateView方法,如果factory創建了view,則就返回這個view,就不會走createView的邏輯了。

View view;
if (mFactory2 != null) {
    view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
    view = mFactory.onCreateView(name, context, attrs);
} else {
    view = null;
}
if (view == null && mPrivateFactory != null) {
    view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
if (view == null) {
    final Object lastContext = mConstructorArgs[0];
    mConstructorArgs[0] = context;
try {
    if (-1 == name.indexOf('.')) {
    view = onCreateView(parent, name, attrs);
} else {
    view = createView(name, null, attrs);
}

所以我們可以給LayoutInflate設置一個factory,由於factory2在最前邊我們設置factory2,在PluginHostActivity的onCreate方法中設置下列代碼

getLayoutInflater().setFactory2(new WidgetLayoutInflaterFactory());

WidgetLayoutInflaterFactory實現Factory2接口,自己接管了自定義控件的創建過程。詳細可以見Demo的WidgetLayoutInflaterFactory類

class WidgetLayoutInflaterFactory implements LayoutInflater.Factory2 {

    private final HashMap> sConstructorMap = new
            HashMap>();
    private final Class[] mConstructorSignature = new Class[]{Context.class, AttributeSet.class};
    private final Object[] mConstructorArgs = new Object[2];

    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        if (name.equals("view")) {
            name = attrs.getAttributeValue(null, "class");
        }
        //如果沒有'.',說明是Android系統控件,直接返回null,讓系統自己createView
        if (-1 == name.indexOf('.')) {
            return null;
        }
        Context lastContext = (Context) mConstructorArgs[0];
        mConstructorArgs[0] = context;
        Class clazz = null;
        //先從本地緩存讀取
        Constructor constructor = sConstructorMap.get(name);
        try {
            if (constructor == null) {
                //沒有緩存,根據類名創建Constructor對象存入緩存
                // Class not found in the cache, see if it's real, and try to add it
                clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);
                constructor = clazz.getConstructor(mConstructorSignature);
                sConstructorMap.put(name, constructor);
            }
            Object[] args = mConstructorArgs;
            args[1] = attrs;
            constructor.setAccessible(true);
            return constructor.newInstance(args);
        } catch (NoSuchMethodException e) {
            InflateException ie = new InflateException(attrs.getPositionDescription()
                    + ": Error inflating class " + name);
            ie.initCause(e);
            throw ie;
        } catch (ClassCastException e) {
            // If loaded class is not a View subclass
            InflateException ie = new InflateException(attrs.getPositionDescription()
                    + ": Class is not a View " + name);
            ie.initCause(e);
            throw ie;
        } catch (ClassNotFoundException e) {
            // If loaded class is not a View subclass
            InflateException ie = new InflateException(attrs.getPositionDescription()
                    + ": Class not found " + name);
            ie.initCause(e);
            throw ie;
        } catch (Exception e) {
            InflateException ie = new InflateException(attrs.getPositionDescription()
                    + ": Error inflating class " + (clazz == null ? "" : clazz.getName()));
            ie.initCause(e);
            throw ie;
        } finally {
            mConstructorArgs[0] = lastContext;
            mConstructorArgs[1] = null;
        }
    }

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return onCreateView(null, name, context, attrs);
    }

}

思考

插件包傳輸過程需要加密,然而虛擬機不能解密,只能解密後加載入虛擬機,要及時刪除解密後的插件。
 
  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved