framework外掛化技術-資源載入(免安裝)

gogogo在掘金發表於2018-09-02

在上一篇《framework外掛化技術-類載入》說到了一種framework特性外掛化技術中的類載入方式解決lib的動態載入問題,但是我們都知道在Android裡面,除了程式碼意外,還有一塊非常重要的領域就是資源。
plugin裡的程式碼是不能按照普通的方式直接載入資源的,因為plugin裡拿到的context都是從app傳過來的,如果按照普通的方式載入資源,載入的都是app的資源,無法載入到plugin apk裡的資源。所以我們需要在plugin的feature和res中間加入一箇中間層:PluginResLoader,來專門負責載入資源,結構如下:

framework外掛化技術-資源載入(免安裝)

構造Resources

我們都知道在Android中Resources類是專門負責載入資源的,所以PluginResLoader的首要任務就是構建Resources。我們平時在Activity中通過getResources()的介面獲取Resources物件,但是這裡獲取到的Resources是與應用Context繫結的Resources,我們需要構造plugin的Resources,這裡我們可以看下如何構造Resources:

framework外掛化技術-資源載入(免安裝)
我們可以看到建構函式裡需要傳入三個引數,AssetManager,DisplayMetrics,Configuration。後面兩個引數我們可以通過app傳過來的context獲得,所以現在問題可以轉換為構造AssetManager。我們發現AssetManager的建構函式並不公開,所以這裡只能通過反射的方式構造。同時AssetManager還有一個介面可以直接傳入資源的路徑:

public int addAssetPath(String path)
複製程式碼

所以,綜上所述,我們可以得到構造Resources的方法:
PluginResLoader:

public Resources getResources(Context context) {
    AssetManager assetManager = null;

    try {
        mPluginPath = context.getDataDir().getPath() + "/plugin.apk";
        assetManager = AssetManager.class.newInstance();
        if(assetManager != null) {
            try {
                AssetManager.class.getDeclaredMethod("addAssetPath", String.class)
                        .invoke(assetManager, mPluginPath);
            } catch (InvocationTargetException e) {
                assetManager = null;
                e.printStackTrace();
            } catch (NoSuchMethodException e) {
                assetManager = null;
                e.printStackTrace();
            }
        }

    } catch (InstantiationException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
    Resources resources;
    if(assetManager != null) {
        resources = new Resources(assetManager,
                context.getResources().getDisplayMetrics(),
                context.getResources().getConfiguration());
    } else {
        resources = context.getResources();
    }
    return resources;
}
複製程式碼

這樣,我們就可以通過構造出來的Resources訪問plugin上的資源。 但是在實際的操作中,我們發現獲取了Resources還不能解決所有的資源訪問問題。style和layout依然無法直接獲取。

獲取id

由於plugin不是已安裝的apk,所以我們不能使用Resources的getIdentifier介面來直接獲取資源id。但是我們知道,apk的資源在編譯階段都會生成R檔案,所以我們可以通過反射plugin apk中的R檔案的方式來獲取id:

    /**
     * get resource id through reflect the R.java in plugin apk.
     * @param context the base context from app.
     * @param type res type
     * @param name res name
     * @return res id.
     */
    public int getIdentifier(Context context, String type, String name) {
        if(context == null) {
            Log.w(TAG, "getIdentifier: the context is null");
            return -1;
        }

        if(RES_TYPE_STYLEABLE.equals(type)) {
            return reflectIdForInt(context, type, name);
        }

        return getResources(context).getIdentifier(name, type, PLUGIN_RES_PACKAGE_NAME);
    }

    /**
     * get resource id array through reflect the R.java in plugin apk.
     * eg: get {@link R.styleable}
     * @param context he base context from app.
     * @param type res type
     * @param name res name
     * @return res id
     */
    public int[] getIdentifierArray(Context context, String type, String name) {
        if(context == null) {
            Log.w(TAG, "getIdentifierArray: the context is null");
            return null;
        }

        Object ids = reflectId(context, type, name);
        return  ids instanceof int[] ? (int[])ids : null;
    }

    private Object reflectId(Context context, String type, String name) {
        ClassLoader classLoader = context.getClassLoader();
        try {
            String clazzName = PLUGIN_RES_PACKAGE_NAME + ".R$" + type;
            Class<?> clazz = classLoader.loadClass(clazzName);
            if(clazz != null) {
                Field field = clazz.getField(name);
                field.setAccessible(true);
                return field.get(clazz);
            }

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return -1;
    }

    private int reflectIdForInt(Context context, String type, String name) {
        Object id = reflectId(context, type, name);
        return id instanceof Integer ? (int)id : -1;
    }
複製程式碼

構造LayoutInflater

我們都知道,獲取layout資源無法直接通過Resources拿到,而需要通過LayoutInflater來獲取。一般的方法:

LayoutInflater inflater = LayoutInflater.from(context); 
複製程式碼

而這裡穿進去的context是應用傳進來的context,很明顯通過應用的context我們是無法獲取到plugin裡的資源的。那麼我們如何獲取plugin裡的layout資源呢?是否需要構造一個自己的LayoutInflater和context?我們進一步去看一下原始碼:

public static LayoutInflater from(Context context) {
        LayoutInflater LayoutInflater =
                (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        if (LayoutInflater == null) {
            throw new AssertionError("LayoutInflater not found.");
        }
        return LayoutInflater;
}
複製程式碼

我們可以看到是通過context的getsystemService方法來獲取的LayoutInflater。進入到Context的原始碼我們可以看到Context是一個抽象類,並沒有getSystemService的實現。那這個實現到底在那裡呢?這裡就不得不去研究一下Context的整個結構。

Context

framework外掛化技術-資源載入(免安裝)
我們可以看到我們平時用到的元件,基本都是出自於ContextWrapper,說明ContextWrapper是Context的一個基本實現。 我們看一下ContextWrapper獲取SystemSerivce的方法:

@Override
    public Object getSystemService(String name) {
        return mBase.getSystemService(name);
}
複製程式碼

最終會通過mBase來獲取。同樣的我們還可以看到ContextWrapper裡很多其他的方法也是代理給mBase去實現的。很明顯這是一個裝飾模式。ContextWrapper只是一個Decorator,真正的實現在mBase。那麼我們看下mBase在哪裡建立:

public ContextWrapper(Context base) {
        mBase = base;
    }
    
    /**
     * Set the base context for this ContextWrapper.  All calls will then be
     * delegated to the base context.  Throws
     * IllegalStateException if a base context has already been set.
     * 
     * @param base The new base context for this wrapper.
     */
    protected void attachBaseContext(Context base) {
        if (mBase != null) {
            throw new IllegalStateException("Base context already set");
        }
        mBase = base;
}
複製程式碼

這裡我們可以看到有兩處可以傳進來,一處是建構函式裡,還有一處是通過attachBaseContext方法傳進來。由於我們這裡的lib主要針對Activity實現,所以我們需要看一下在Activity建立伊始,mBase是如何構建的。
我們都知道,Activity的建立是在ActivityThread的performLaunchActivity方法中:

private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
    ...
    //建立base context
    ContextImpl appContext = createBaseContextForActivity(r);
    
    Activity activity = null;
    try {
        ...
        //建立activity
        activity = mInstrumentation.newActivity(
                    cl, component.getClassName(), r.intent);
        ...
    } catch (Exception e) {
        ...
    }
    ...
    if (activity != null) {
        ...
        //繫結base context到activity
        activity.attach(appContext, this, getInstrumentation(), r.token,
                    r.ident, app, r.intent, r.activityInfo, title, r.parent,
                    r.embeddedID, r.lastNonConfigurationInstances, config,
                    r.referrer, r.voiceInteractor, window, r.configCallback);
        ...
    }
    ...
}
複製程式碼

在上述程式碼中,建立了一個ContextImpl物件,以及一個Activity物件,並且將ContextImpl作為base context通過activity的attach方法傳給了Activity:

final void attach(Context context, ActivityThread aThread,
        Instrumentation instr, IBinder token, int ident,
        Application application, Intent intent, ActivityInfo info,
        CharSequence title, Activity parent, String id,
        NonConfigurationInstances lastNonConfigurationInstances,
        Configuration config, String referrer, IVoiceInteractor voiceInteractor,
        Window window, ActivityConfigCallback activityConfigCallback) {
    attachBaseContext(context);
    ...
}
複製程式碼

還記得我們在前面提到的ContextWrapper中構建mBase的兩個地方,一個是建構函式,還有一個便是attachBaseContext方法。所以至此我們可以解答前面疑惑的mBase物件是什麼了,原來就是在ActivityThread建立Activity的同時建立的ContextImpl物件。也就是說,其實contexImpl是context的真正實現。
回到我們前面討論的getSystemService問題,我們到/frameworks/base/core/java/android/app/ContextImpl.java中看:

    @Override
    public Object getSystemService(String name) {
        return SystemServiceRegistry.getSystemService(this, name);
    }
複製程式碼

再轉到/frameworks/base/core/java/android/app/SystemServiceRegistry.java,我們找到註冊LayoutInflater服務的地方:

registerService(Context.LAYOUT_INFLATER_SERVICE, LayoutInflater.class,
        new CachedServiceFetcher<LayoutInflater>() {
        @Override
        public LayoutInflater createService(ContextImpl ctx) {
        return new PhoneLayoutInflater(ctx.getOuterContext());
}});
複製程式碼

這裡我們可以看到系統的LayoutInflater實現其實是PhoneLayoutInflater。這樣好辦了。我們可以仿造PhoneLayoutInflater構造一個PluginLayoutInflater就好了。但是在前面的討論中我們知道LayoutInflater是無法直接建立的,而是通過Context間接建立的,所以這裡我們還需要構造一個PluginContext,仿造原有的Context的方式,在getSystemService中返回我們的PluginLayoutInflater。具體實現如下:
PluginContextWrapper:

public class PluginContextWrapper extends ContextWrapper {
    private Resources mResource;
    private Resources.Theme mTheme;

    public PluginContextWrapper(Context base) {
        super(base);
        mResource = PluginResLoader.getsInstance().getResources(base);
        mTheme = PLuginResLoader.getsInstance().getTheme(base);
    }

    @Override
    public Resources getResources() {
        return mResource;
    }

    @Override
    public Resources.Theme getTheme() {
        return mTheme;
    }

    @Override
    public Object getSystemService(String name) {
        if(Context.LAYOUT_INFLATER_SERVICE.equals(name)) {
            return new PluginLayoutInflater(this);
        }
        return super.getSystemService(name);
    }
}
複製程式碼

PluginLayoutInflater:

public class PluginLayoutInflater extends LayoutInflater {

    private static final String[] sClassPrefixList = {
            "android.widget",
            "android.webkit",
            "android.app"
    };

    protected PluginLayoutInflater(Context context) {
        super(context);
    }

    protected PluginLayoutInflater(LayoutInflater original, Context newContext) {
        super(original, newContext);
    }

    @Override
    public LayoutInflater cloneInContext(Context newContext) {
        return new PluginLayoutInflater(this, newContext);
    }

    @Override
    protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
        for (String prefix : sClassPrefixList) {
            View view = createView(name, prefix, attrs);
            if(view != null) {
                return view;
            }
        }
        return super.onCreateView(name, attrs);

    }
}
複製程式碼

PluginResLoader:

public Context getContext(Context base) {
    return new PluginContextWrapper(base);
}
複製程式碼

這樣在plugin中如果需要載入佈局只需要以這樣的方式即可載入:

LayoutInflater inflater = LayoutInflater.from(
                        PluginResLoader.getsInstance().getContext(context));
複製程式碼

其中應用的傳過來的context可以作為pluginContext的base,這樣我們可以獲取很多應用的資訊。

構造plugin主題

我們都知道主題是一系列style的集合。而一般我們設定主題的範圍是app或者是Activity,但是如果我們只純粹希望Theme單純應用於我們的lib,而我們的lib並不是一個app或者是一個Activity,我們希望我們的lib能夠有一個統一的風格。那怎麼樣構建主題並且應用與plugin呢? 首先我們需要看下在Google的原聲控制元件裡是如何讀取主題定義的屬性的,比如在 /frameworks/base/core/java/android/widget/Toolbar.java 控制元件裡是這樣讀取的:

public Toolbar(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);

        final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Toolbar,
                defStyleAttr, defStyleRes);
        ...
}
複製程式碼

這裡的attrs在兩參建構函式裡傳進來:

public Toolbar(Context context, AttributeSet attrs) {
        this(context, attrs, com.android.internal.R.attr.toolbarStyle);
}
複製程式碼

這裡我們讀到兩個關鍵資訊,Toolbar的風格屬性是toolbarStyle,而控制元件通過屬性解析資源的方式是通過context.obtainStyledAttributes拿到TypedArray來獲取資源。
那Google又是在哪裡定義了toolbarStyle的呢?檢視Goolge的資原始碼,我們找到在/frameworks/base/core/res/res/values/themes_material.xml 中:

<style name="Theme.Material">
    ...
    <item name="toolbarStyle">@style/Widget.Material.Toolbar</item>
    ...
</style>
複製程式碼

在Theme.Materail主題下定義了toolbarStyle的風格。這裡順便提一下,/frameworks/base/core/res/res/values/themes_material.xml 是Google專門為material風格設立的主題檔案,當然values下的所有檔案都可以合為一個,但是很明顯這樣分開儲存會在程式碼結構上清晰許多。同樣的在/frameworks/base/core/res/res/values/themes.xml 檔案下的Theme主題下也定義了toolbarStyle,這是Android的預設主題,也是所有主題的祖先。關於toolbarStyle的各個主題下的定義,這裡就不一一列舉了,感興趣的童鞋可以直接到原始碼裡看。 到這裡framework把控制元件介面做好,應用只需要在AndroidManifest.xml檔案裡配置Activity或者Application的主題:

<activity android:name=".MainActivity"
            android:theme="@android:style/Theme.Material">
複製程式碼

就可以將Activity介面應用於Material主題,從而Toolbar控制元件就會選取Material主題配置的資源來適配。這樣,就可以達到資源和程式碼的完全解耦,不需要改動程式碼,只需要配置多套資源(比如設定Holo主題,Material主題等等),就可以讓介面顯示成完全不同的樣式。
現在我們回頭看context.obtainStyleAtrributes方法,我們去具體看下這個方法的實現:

public final TypedArray obtainStyledAttributes(
        AttributeSet set, @StyleableRes int[] attrs, @AttrRes int defStyleAttr,
        @StyleRes int defStyleRes) {
    return getTheme().obtainStyledAttributes(
        set, attrs, defStyleAttr, defStyleRes);
}
複製程式碼

這裡可以看到最終是通過getTheme來實現的,也就是最終解析屬性是交給Theme來做的。這裡就可以看到和主題的關聯了。那麼我們繼續往下看下獲取到的主題是什麼,Activity的getTheme實現在/frameworks/base/core/java/android/view/ContextThemeWrapper.java:

    @Override
    public Resources.Theme getTheme() {
        ...
        initializeTheme();
        return mTheme;
    }
複製程式碼

這裡theme的建立在initializeTheme方法裡:

    private void initializeTheme() {
        final boolean first = mTheme == null;
        if (first) {
            
            //建立主題
            mTheme = getResources().newTheme();
            ...
        }
        
        //適配特定主題style
        onApplyThemeResource(mTheme, mThemeResource, first);
    }
    
    protected void onApplyThemeResource(Resources.Theme theme, int resId, boolean first) {
        theme.applyStyle(resId, true);
    }
複製程式碼

這裡我們就可以看到theme的建立是通過Resources的newTheme()方法來建立的,並且通過theme.applyStyle方法將對應的theme資源設定到theme物件中。
至此,我們已經知道如何構建一個theme了,那麼怎麼獲取themeId呢?
我們知道framework是通過讀取Activity或Application設定的theme白噢錢來設定theme物件的,那我們的plugin是否也可以在AndroidManifest.xml檔案裡讀取這樣類似的標籤呢?答案是肯定的。
在AndroidManifest.xml裡,還有個metadata元素可以配置。metadata是一個非常好的資原始碼解耦方式,在metadata裡配置的都是字串,不管是否存在plugin,都不會影響app的編譯及執行,因為metadata的解析都在plugin端。

<meta-data
        android:name="plugin-theme"
        android:value="pluginDefaulTheme"/>
複製程式碼

然後,我們我們就可以得出構建plugin主題的方案了:

framework外掛化技術-資源載入(免安裝)
即,

  1. 解析應用在AndroidManifest檔案中配置的metadata資料。
  2. 獲取到對應應用設定的Theme。
  3. 拿到Theme的styleId
  4. 構造Theme。

這樣我們就可以構造統一風格的plugin了。 具體的實現程式碼如下:
PluginResLoader:

    public Resources.Theme getTheme(Context context) {
        if(context == null) {
            return null;
        }

        Resources.Theme theme = context.getTheme();
        String themeFromMetaData = null;
        if(context instanceof Activity) {
            Activity activity = (Activity)context;
            try {
                ActivityInfo info = activity.getPackageManager().getActivityInfo(activity.getComponentName(),
                        PackageManager.GET_META_DATA);
                if(info != null && info.metaData != null) {
                    themeFromMetaData = info.metaData.getString(THEME_METADATA_NAME);
                }
            } catch (PackageManager.NameNotFoundException e) {
                e.printStackTrace();
            }
        } else {
           //the context is not Activity, 
           //get metadata from Application.
            try {
                ApplicationInfo appInfo = context.getPackageManager()
                        .getApplicationInfo(context.getPackageName(),
                                PackageManager.GET_META_DATA);
                if(appInfo != null && appInfo.metaData != null) {
                    themeFromMetaData = appInfo.metaData.getString(THEME_METADATA_NAME);
                }
            } catch (PackageManager.NameNotFoundException e) {
                e.printStackTrace();
            }
        }

        if(themeFromMetaData == null) {
            //get theme from metadata fail, return the theme from baseContext.
            return theme;
        }

        int themeId = -1;
        if(WIDGET_THEME_NAME.equals(themeFromMetaData)) {
            themeId = getIdentifier(context, "style", WIDGET_THEME_NAME);
        } else {
            Log.w(TAG, "getTheme: the theme from metadata is wrong");
        }

        if(themeId >= 0) {
            theme = getResources(context).newTheme();
            theme.applyStyle(themeId, true);
        }
        return theme;
    }
複製程式碼

相關文章