扣丁書屋

對 Android 應用換膚方案的總結

雖然現在已經有很多不錯的換膚方案,但是這些方案或多或少都存在自己的問題。在這篇文章中,我將對 Android 現有的一些動態換膚方案進行梳理,對其底層實現原理進行分析,然后對開發一個新的換膚方案的可能性進行總結。

1、通過自定義 style 換膚

1.1 方案的基本原理

這種方案是我之前用得比較多的一種方案。我在使用的時候也做了很多的調整。開源版本可以參考 Colorful 這個庫.

在《言葉》中應用的例子

它的實現方式是:用戶提前自定義一些 theme 主題,然后當設置主題的時候將指定主題對應的 id 記錄到本地文件中,當 Activity RESUME 的時候,判斷 Activity 當前的主題是否和之前設置的主題一致,不一致的話就調用當前 Activity 的 recreate() 方法進行重建。

在這種方案中還可以通過如下的方式預定義一些屬性,

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="themed_divider_color" format="color"/>
    <attr name="themed_foreground" format="color"/>
    <!-- .... -->
</resources>

然后在自定義主題中使用為這些預定義屬性賦值,

<style name="Base.AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
    <item name="themed_foreground">@color/warm_theme_foreground</item>
    <item name="themed_background">@color/warm_theme_background</item>
    <!-- ... -->
</style>

最后在布局文件中通過如下的方式引用這些自定義屬性,

<androidx.appcompat.widget.AppCompatTextView
    android:id="@+id/tv"
    android:textColor="?attr/themed_text_color_secondary"
    ... />

<View android:background="?attr/themed_divider_color"
    android:layout_width="match_parent"
    android:layout_height="1px"/>

這種引用方式的好處是只要切換了主題這些自定義屬性可以動態發生變化。

1.2 對該方案的總結

這種方案在換膚之后需要重啟 Activity,代價有些高,特別是當主頁存在多個嵌套 Fragment 的時候,狀態處理起來可能會特別復雜。對于簡單類型的應用,這種方案是一種方便、快捷的選擇。

2、通過 hook LayoutInflater 的換膚方案

2.1 LayoutInflater 的工作原理

通過 Hook LayoutInflater 進行換膚的方案是眾多開源方案中比較常見的一種。在分析這種方案之前,我們最好先了解下 LayoutInflater 的工作原理。

通常當我們想要自定義 Layout 的 Factory 的時候可以調用下面兩個方法將我們的 Factory 設置到系統的 LayoutInflater 中,

public abstract class LayoutInflater {
    public void setFactory(Factory factory) {
        if (mFactorySet) throw new IllegalStateException("A factory has already been set on this LayoutInflater");
        if (factory == null) throw new NullPointerException("Given factory can not be null");
        mFactorySet = true;
        if (mFactory == null) {
            mFactory = factory;
        } else {
            mFactory = new FactoryMerger(factory, null, mFactory, mFactory2);
        }
    }

    public void setFactory2(Factory2 factory) {
        if (mFactorySet) throw new IllegalStateException("A factory has already been set on this LayoutInflater");
        if (factory == null) throw new NullPointerException("Given factory can not be null");
        mFactorySet = true;
        if (mFactory == null) {
            mFactory = mFactory2 = factory;
        } else {
            mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
        }
    }
    // ...
}

從上面的兩個方法看出,setFactory() 方法底層有防重入校驗,所以,如果想要手動進行賦值,需要使用反射修改 mFactorySet、mFactorymFactory2。

那么 mFactorymFactory2 時如何使用的呢?

當我們調用 inflator 從 xml 中加載控件的時候,將會走到如下代碼真正執行加載操作,

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {
        // ....
        final Context inflaterContext = mContext;
        final AttributeSet attrs = Xml.asAttributeSet(parser);
        Context lastContext = (Context) mConstructorArgs[0];
        mConstructorArgs[0] = inflaterContext;
        View result = root;

        try {
            advanceToRootNode(parser);
            final String name = parser.getName();

            // 處理 merge 標簽
            if (TAG_MERGE.equals(name)) {
                rInflate(parser, root, inflaterContext, attrs, false);
            } else {
                // 從 xml 中加載布局控件
                final View temp = createViewFromTag(root, name, inflaterContext, attrs);
                // 生成布局參數 LayoutParams
                ViewGroup.LayoutParams params = null;
                if (root != null) {
                    params = root.generateLayoutParams(attrs);
                    if (!attachToRoot) {
                        temp.setLayoutParams(params);
                    }
                }
                // 加載子控件
                rInflateChildren(parser, temp, attrs, true);
                // 添加到根控件
                if (root != null && attachToRoot) {
                    root.addView(temp, params);
                }
                if (root == null || !attachToRoot) {
                    result = temp;
                }
            }

        } catch (XmlPullParserException e) {/*...*/}
        return result;
    }
}

先來看通過 tag 創建 view 的邏輯,

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) {
    // 老的布局方式
    if (name.equals("view")) {
        name = attrs.getAttributeValue(null, "class");
    }
    // 處理 theme
    if (!ignoreThemeAttr) {
        final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
        final int themeResId = ta.getResourceId(0, 0);
        if (themeResId != 0) {
            context = new ContextThemeWrapper(context, themeResId);
        }
        ta.recycle();
    }
    try {
        View view = tryCreateView(parent, name, context, attrs);
        if (view == null) {
            final Object lastContext = mConstructorArgs[0];
            mConstructorArgs[0] = context;
            try {
                if (-1 == name.indexOf('.')) {
                    view = onCreateView(context, parent, name, attrs);
                } else {
                    view = createView(context, name, null, attrs);
                }
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        }
        return view;
    } catch (InflateException e) {
        // ...
    }
}

public final View tryCreateView(View parent, String name, Context context, AttributeSet attrs) {
    if (name.equals(TAG_1995)) {
        return new BlinkLayout(context, attrs);
    }

    // 優先使用 mFactory2 創建 view,mFactory2 為空則使用 mFactory,否則使用 mPrivateFactory
    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);
    }
    return view;
}

可以看出,這里優先使用 mFactory2 創建 view,mFactory2 為空則使用 mFactory,否則使用 mPrivateFactory 加載 view。所以,如果我們想要對 view 創建過程進行 hook,就應該 hook 這里的 mFactory2。因為它的優先級最高。

注意到這里的 inflate 方法中并沒有循環,所以,第一次的時候只能加載根布局。那么根布局內的子控件是如何加載的呢?這就用到了 rInflateChildren 這個方法,

final void rInflateChildren(XmlPullParser parser, View parent, AttributeSet attrs,
        boolean finishInflate) throws XmlPullParserException, IOException {
    rInflate(parser, parent, parent.getContext(), attrs, finishInflate);
}

void rInflate(XmlPullParser parser, View parent, Context context, AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {

    final int depth = parser.getDepth();
    int type;
    boolean pendingRequestFocus = false;

    while (((type = parser.next()) != XmlPullParser.END_TAG || parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
        if (type != XmlPullParser.START_TAG) continue;

        final String name = parser.getName();
        if (TAG_REQUEST_FOCUS.equals(name)) {
            // 處理 requestFocus 標簽
            pendingRequestFocus = true;
            consumeChildElements(parser);
        } else if (TAG_TAG.equals(name)) {
            // 處理 tag 標簽
            parseViewTag(parser, parent, attrs);
        } else if (TAG_INCLUDE.equals(name)) {
            // 處理 include 標簽
            if (parser.getDepth() == 0) {
                throw new InflateException("<include /> cannot be the root element");
            }
            parseInclude(parser, context, parent, attrs);
        } else if (TAG_MERGE.equals(name)) {
            // 處理 merge 標簽
            throw new InflateException("<merge /> must be the root element");
        } else {
            // 這里處理的是普通的 view 標簽
            final View view = createViewFromTag(parent, name, context, attrs);
            final ViewGroup viewGroup = (ViewGroup) parent;
            final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
            // 繼續處理子控件
            rInflateChildren(parser, view, attrs, true);
            viewGroup.addView(view, params);
        }
    }
    if (pendingRequestFocus) {
        parent.restoreDefaultFocus();
    }
    if (finishInflate) {
        parent.onFinishInflate();
    }
}

注意到該方法內部又調用了 createViewFromTagrInflateChildren 方法,也就是說,這里通過遞歸的方式實現對整個 view 樹的遍歷,從而將整個 xml 加載為 view 樹。

以上是安卓的 LayoutInflater 從 xml 中加載控件的邏輯,可以看出我們可以通過 hook mFactory2 實現對創建 view 的過程的“監聽”。

2.2 Android-Skin-Loader

1. 基本的換膚流程

學習了 Hook LayoutInflator 的底層原理之后,我們來看幾個基于這種原理實現的換膚方案。首先是 Android-Skin-Loader 這個庫,

這個庫需要你覆寫 Activity 等。以 Activity 為例,

public class BaseActivity extends Activity implements ISkinUpdate, IDynamicNewView{

    private SkinInflaterFactory mSkinInflaterFactory;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mSkinInflaterFactory = new SkinInflaterFactory();
        getLayoutInflater().setFactory(mSkinInflaterFactory);
    }

    // ...
}

可以看出這里將自定義的 Factory 設置給了 LayoutInflator。這里的自定義 LayoutInflater.Factory 的實現是,

public class SkinInflaterFactory implements Factory {

    private static final boolean DEBUG = true;
    private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        // 讀取自定義屬性 enable,這里用了自定義的 namespace
        boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
        if (!isSkinEnable){
            return null;
        }
        // 創建 view
        View view = createView(context, name, attrs);
        if (view == null){
            return null;
        }
        parseSkinAttr(context, attrs, view);
        return view;
    }

    private View createView(Context context, String name, AttributeSet attrs) {
        View view = null;
        try {
            // 兼容低版本創建 view 的邏輯(低版本是沒有完整包名)
            if (-1 == name.indexOf('.')){
                if ("View".equals(name)) {
                    view = LayoutInflater.from(context).createView(name, "android.view.", attrs);
                } 
                if (view == null) {
                    view = LayoutInflater.from(context).createView(name, "android.widget.", attrs);
                } 
                if (view == null) {
                    view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs);
                } 
            } else {
                // 新的創建 view 的邏輯
                view = LayoutInflater.from(context).createView(name, null, attrs);
            }
        } catch (Exception e) { 
            view = null;
        }
        return view;
    }

    private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
        List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();
        // 對 xml 中控件的屬性進行解析
        for (int i = 0; i < attrs.getAttributeCount(); i++){
            String attrName = attrs.getAttributeName(i);
            String attrValue = attrs.getAttributeValue(i);
            // 判斷屬性是否支持,屬性是預定義的
            if(!AttrFactory.isSupportedAttr(attrName)){
                continue;
            }
            // 如果是引用類型的屬性值
            if(attrValue.startsWith("@")){
                try {
                    int id = Integer.parseInt(attrValue.substring(1));
                    String entryName = context.getResources().getResourceEntryName(id);
                    String typeName = context.getResources().getResourceTypeName(id);
                    // 加入屬性列表
                    SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
                    if (mSkinAttr != null) {
                        viewAttrs.add(mSkinAttr);
                    }
                } catch (NumberFormatException e) {/*...*/}
            }
        }
        if(!ListUtils.isEmpty(viewAttrs)){
            // 構建該控件的屬性關系
            SkinItem skinItem = new SkinItem();
            skinItem.view = view;
            skinItem.attrs = viewAttrs;
            mSkinItems.add(skinItem);
            if(SkinManager.getInstance().isExternalSkin()){
                skinItem.apply();
            }
        }
    }
}

這里自定義了一個 xml 屬性,用來指定是否啟用換膚配置。然后在創建 view 的過程中解析 xml 中定義的 view 的屬性信息,比如,background 和 textColor 等屬性。并將其對應的屬性、屬性值和控件以映射的形式記錄到緩存中。當發生換膚的時候根據這里的映射關系在代碼中更新控件的屬性信息。

以背景的屬性信息為例,看下其 apply 操作,

public class BackgroundAttr extends SkinAttr {

    @Override
    public void apply(View view) {
        if(RES_TYPE_NAME_COLOR.equals(attrValueTypeName)){
            // 注意這里獲取屬性值的時候是通過 SkinManager 的方法獲取的
            view.setBackgroundColor(SkinManager.getInstance().getColor(attrValueRefId));
        }else if(RES_TYPE_NAME_DRAWABLE.equals(attrValueTypeName)){
            Drawable bg = SkinManager.getInstance().getDrawable(attrValueRefId);
            view.setBackground(bg);
        }
    }
}

如果是動態添加的 view,比如在 java 代碼中,該庫提供了 dynamicAddSkinEnableView 等方法來動態添加映射關系到緩存中。

在 activity 的生命周期方法中注冊監聽換膚事件(觀察者模式),

public class BaseActivity extends Activity implements ISkinUpdate, IDynamicNewView{
    @Override
    protected void onResume() {
        super.onResume();
        SkinManager.getInstance().attach(this);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        SkinManager.getInstance().detach(this);
        // 清理緩存數據
        mSkinInflaterFactory.clean();
    }

    @Override
    public void onThemeUpdate() {
        if(!isResponseOnSkinChanging){
            return;
        }
        mSkinInflaterFactory.applySkin();
    }
    // ... 
}

當換膚的時候會通知到 Activity 并觸發 onThemeUpdate() 方法,這里調用了 SkinInflaterFactory 的 apply 方法。SkinInflaterFactory 的 apply 方法中對緩存的屬性信息遍歷更新實現換膚。

2. 皮膚包的加載邏輯

皮膚包的記載邏輯,即通過自定義的 AssetManager 實現,類似于插件化,

public void load(String skinPackagePath, final ILoaderListener callback) {
    new AsyncTask<String, Void, Resources>() {

        protected void onPreExecute() {
            if (callback != null) {
                callback.onStart();
            }
        };

        @Override
        protected Resources doInBackground(String... params) {
            try {
                if (params.length == 1) {
                    String skinPkgPath = params[0];

                    File file = new File(skinPkgPath); 
                    if(file == null || !file.exists()){
                        return null;
                    }

                    PackageManager mPm = context.getPackageManager();
                    PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
                    skinPackageName = mInfo.packageName;

                    AssetManager assetManager = AssetManager.class.newInstance();
                    Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                    addAssetPath.invoke(assetManager, skinPkgPath);

                    Resources superRes = context.getResources();
                    Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());

                    SkinConfig.saveSkinPath(context, skinPkgPath);

                    skinPath = skinPkgPath;
                    isDefaultSkin = false;
                    return skinResource;
                }
                return null;
            } catch (Exception e) { /*...*/ }
        };

        protected void onPostExecute(Resources result) {
            mResources = result;
            if (mResources != null) {
                if (callback != null) callback.onSuccess();
                notifySkinUpdate();
            }else{
                isDefaultSkin = true;
                if (callback != null) callback.onFailed();
            }
        };
    }.execute(skinPackagePath);
}

然后獲取值的時候使用如下方法,

public int getColor(int resId){
    int originColor = context.getResources().getColor(resId);
    if(mResources == null || isDefaultSkin){
        return originColor;
    }

    String resName = context.getResources().getResourceEntryName(resId);
    int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
    int trueColor = 0;

    try{
        trueColor = mResources.getColor(trueResId);
    }catch(NotFoundException e){
        e.printStackTrace();
        trueColor = originColor;
    }
    return trueColor;
}

3. 對這種方案的幾個總結

  • 換膚需要繼承自定義 activity
  • 皮膚包和 APK 如果使用了資源混淆加載的時候就會出現問題
  • 沒處理屬性值通過 ?attr 的形式引用的情況
  • 每個換膚的屬性需要自己注冊并實現
  • 有些控件的一些屬性可能沒有提供對應的 java 方法,因此在代碼中換膚就行不通
  • 沒有處理使用 style 的情況
  • 基于 android.app.Activity 實現,版本太老
  • 在 inflator 創建 view 的時候,其實只做了對屬性的攔截處理操作,可以通過代理系統的 Factory 實現創建 view 的操作

2.3 ThemeSkinning

這個庫是基于上面的 Android-Skin-Loader 開發的,在其基礎之上做了許多的調整,其地址是 ThemeSkinning

1. 基于 AppCompactActivity 實現

該庫基于 AppCompactActivity 和 LayoutInflaterCompat.setFactory2 開發,

public class SkinBaseActivity extends AppCompatActivity implements ISkinUpdate, IDynamicNewView {

    private SkinInflaterFactory mSkinInflaterFactory;
    private final static String TAG = "SkinBaseActivity";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        mSkinInflaterFactory = new SkinInflaterFactory(this);
        LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
        super.onCreate(savedInstanceState);
        changeStatusColor();
    }

    // ...
}

同時,該庫也提供了修改狀態欄的方法,雖然能力比較有限。(換膚的時候也應該考慮狀態欄和底部導航欄的適配情況)

2. SkinInflaterFactory 的調整

public class SkinInflaterFactory implements LayoutInflater.Factory2 {

    private Map<View, SkinItem> mSkinItemMap = new HashMap<>();
    private AppCompatActivity mAppCompatActivity;

    public SkinInflaterFactory(AppCompatActivity appCompatActivity) {
        this.mAppCompatActivity = appCompatActivity;
    }

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

    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        // 沿用之前的一些邏輯
        boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
        AppCompatDelegate delegate = mAppCompatActivity.getDelegate();
        View view = delegate.createView(parent, name, context, attrs);

        // 對字體兼容做了支持,這里是通過靜態方式將其緩存到內存,動態新增和移除,加載字體之后調用 textview 的 settypeface 方法替換
        if (view instanceof TextView && SkinConfig.isCanChangeFont()) {
            TextViewRepository.add(mAppCompatActivity, (TextView) view);
        }

        if (isSkinEnable || SkinConfig.isGlobalSkinApply()) {
            if (view == null) {
                // 創建 view 的邏輯做了調整
                view = ViewProducer.createViewFromTag(context, name, attrs);
            }
            if (view == null) {
                return null;
            }
            parseSkinAttr(context, attrs, view);
        }
        return view;
    }

    // ...
}

3. view 的創建邏輯

這里只不過將之前的創建 View 的操作收攏到了一個類中,

class ViewProducer {
    private static final Object[] mConstructorArgs = new Object[2];
    private static final Map<String, Constructor<? extends View>> sConstructorMap = new ArrayMap<>();
    private static final Class<?>[] sConstructorSignature = new Class[]{Context.class, AttributeSet.class};
    private static final String[] sClassPrefixList = {"android.widget.", "android.view.", "android.webkit."};

    static View createViewFromTag(Context context, String name, AttributeSet attrs) {
        if (name.equals("view")) {
            name = attrs.getAttributeValue(null, "class");
        }

        try {
            // 構造參數,緩存,復用
            mConstructorArgs[0] = context;
            mConstructorArgs[1] = attrs;

            if (-1 == name.indexOf('.')) {
                for (int i = 0; i < sClassPrefixList.length; i++) {
                    final View view = createView(context, name, sClassPrefixList[i]);
                    if (view != null) {
                        return view;
                    }
                }
                return null;
            } else {
                // 通過構造方法創建 view
                return createView(context, name, null);
            }
        } catch (Exception e) {
            return null;
        } finally {
            mConstructorArgs[0] = null;
            mConstructorArgs[1] = null;
        }
    }

    // ...
}

4. 屬性解析對 style 做了兼容處理

private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
    List<SkinAttr> viewAttrs = new ArrayList<>();
    for (int i = 0; i < attrs.getAttributeCount(); i++) {
        String attrName = attrs.getAttributeName(i);
        String attrValue = attrs.getAttributeValue(i);
        if ("style".equals(attrName)) {
            // 對 style 的處理,從 theme 中獲取 TypedArray 然后獲取 resource id,再獲取對應的信息
            int[] skinAttrs = new int[]{android.R.attr.textColor, android.R.attr.background};
            TypedArray a = context.getTheme().obtainStyledAttributes(attrs, skinAttrs, 0, 0);
            int textColorId = a.getResourceId(0, -1);
            int backgroundId = a.getResourceId(1, -1);
            if (textColorId != -1) {
                String entryName = context.getResources().getResourceEntryName(textColorId);
                String typeName = context.getResources().getResourceTypeName(textColorId);
                SkinAttr skinAttr = AttrFactory.get("textColor", textColorId, entryName, typeName);
                if (skinAttr != null) {
                    viewAttrs.add(skinAttr);
                }
            }
            if (backgroundId != -1) {
                String entryName = context.getResources().getResourceEntryName(backgroundId);
                String typeName = context.getResources().getResourceTypeName(backgroundId);
                SkinAttr skinAttr = AttrFactory.get("background", backgroundId, entryName, typeName);
                if (skinAttr != null) {
                    viewAttrs.add(skinAttr);
                }
            }
            a.recycle();
            continue;
        }
        if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
            // 老邏輯
            try {
                //resource id
                int id = Integer.parseInt(attrValue.substring(1));
                if (id == 0) continue;
                String entryName = context.getResources().getResourceEntryName(id);
                String typeName = context.getResources().getResourceTypeName(id);
                SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
                if (mSkinAttr != null) {
                    viewAttrs.add(mSkinAttr);
                }
            } catch (NumberFormatException e) { /*...*/ }
        }
    }
    if (!SkinListUtils.isEmpty(viewAttrs)) {
        SkinItem skinItem = new SkinItem();
        skinItem.view = view;
        skinItem.attrs = viewAttrs;
        mSkinItemMap.put(skinItem.view, skinItem);
        if (SkinManager.getInstance().isExternalSkin() ||
                SkinManager.getInstance().isNightMode()) {//如果當前皮膚來自于外部或者是處于夜間模式
            skinItem.apply();
        }
    }
}

5. 對 fragment 的處理

在 Fragment 的生命周期方法結束的時候從緩存當中移除指定的 View,

@Override
public void onDestroyView() {
    removeAllView(getView());
    super.onDestroyView();
}

protected void removeAllView(View v) {
    if (v instanceof ViewGroup) {
        ViewGroup viewGroup = (ViewGroup) v;
        for (int i = 0; i < viewGroup.getChildCount(); i++) {
            removeAllView(viewGroup.getChildAt(i));
        }
        removeViewInSkinInflaterFactory(v);
    } else {
        removeViewInSkinInflaterFactory(v);
    }
}

6. 對這種換膚方案的幾個總結

  • 相對第一個框架改進了很多
  • 沒必要區分夜間主題

2.4 Android-skin-support

相比于上面的庫 Android-skin-support 的 star 數量更多,代碼也更加先進(利用了一些新的特性)。

1. 基于 activity lifecycle 自動注冊 layoutinflator.factory

public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {

    private SkinActivityLifecycle(Application application) {
        application.registerActivityLifecycleCallbacks(this);
        installLayoutFactory(application);
        // 注冊監聽
        SkinCompatManager.getInstance().addObserver(getObserver(application));
    }

    @Override
    public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
        if (isContextSkinEnable(activity)) {
            installLayoutFactory(activity);
            // 更新 acitvity 的窗口的背景
            updateWindowBackground(activity);
            // 觸發換膚...如果 view 沒有創建是不是就容易導致 NPE?
            if (activity instanceof SkinCompatSupportable) {
                ((SkinCompatSupportable) activity).applySkin();
            }
        }
    }

    private void installLayoutFactory(Context context) {
        try {
            LayoutInflater layoutInflater = LayoutInflater.from(context);
            LayoutInflaterCompat.setFactory2(layoutInflater, getSkinDelegate(context));
        } catch (Throwable e) { /* ... */ }
    }

    // 獲取 LayoutInflater.Factory2,這里加了一層緩存
    private SkinCompatDelegate getSkinDelegate(Context context) {
        if (mSkinDelegateMap == null) {
            mSkinDelegateMap = new WeakHashMap<>();
        }
        SkinCompatDelegate mSkinDelegate = mSkinDelegateMap.get(context);
        if (mSkinDelegate == null) {
            mSkinDelegate = SkinCompatDelegate.create(context);
            mSkinDelegateMap.put(context, mSkinDelegate);
        }
        return mSkinDelegate;
    }
    // ...
}

這里的 LayoutInflaterCompat.setFactory2 方法的邏輯是,

public final class LayoutInflaterCompat {

    public static void setFactory2(LayoutInflater inflater, LayoutInflater.Factory2 factory) {
        inflater.setFactory2(factory);
        if (Build.VERSION.SDK_INT < 21) {
            final LayoutInflater.Factory f = inflater.getFactory();
            if (f instanceof LayoutInflater.Factory2) {
                forceSetFactory2(inflater, (LayoutInflater.Factory2) f);
            } else {
                forceSetFactory2(inflater, factory);
            }
        }
    }

    // 通過反射的方式直接修改 mFactory2 字段
    private static void forceSetFactory2(LayoutInflater inflater, LayoutInflater.Factory2 factory) {
        if (!sCheckedField) {
            try {
                sLayoutInflaterFactory2Field = LayoutInflater.class.getDeclaredField("mFactory2");
                sLayoutInflaterFactory2Field.setAccessible(true);
            } catch (NoSuchFieldException e) { /* ... */ }
            sCheckedField = true;
        }
        if (sLayoutInflaterFactory2Field != null) {
            try {
                sLayoutInflaterFactory2Field.set(inflater, factory);
            } catch (IllegalAccessException e) { /* ... */ }
        }
    }
    // ...
}

2. LayoutInflater.Factory2 的實現邏輯

public class SkinCompatDelegate implements LayoutInflater.Factory2 {
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        View view = createView(parent, name, context, attrs);
        if (view == null) return null;
        // 加入緩存
        if (view instanceof SkinCompatSupportable) {
            mSkinHelpers.add(new WeakReference<>((SkinCompatSupportable) view));
        }
        return view;
    }

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        View view = createView(null, name, context, attrs);
        if (view == null) return null;
        // 加入緩存,繼承這個接口的主要是 view 和 activity 這些
        if (view instanceof SkinCompatSupportable) {
            mSkinHelpers.add(new WeakReference<>((SkinCompatSupportable) view));
        }
        return view;
    }

    public View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        // view 生成邏輯被包裝成了 SkinCompatViewInflater
        if (mSkinCompatViewInflater == null) {
            mSkinCompatViewInflater = new SkinCompatViewInflater();
        }
        List<SkinWrapper> wrapperList = SkinCompatManager.getInstance().getWrappers();
        for (SkinWrapper wrapper : wrapperList) {
            Context wrappedContext = wrapper.wrapContext(mContext, parent, attrs);
            if (wrappedContext != null) {
                context = wrappedContext;
            }
        }
        // 
        return mSkinCompatViewInflater.createView(parent, name, context, attrs);
    }
    // ...
}

3. SkinCompatViewInflater 獲取 view 的邏輯

上述方法中 SkinCompatViewInflater 獲取 view 的邏輯如下,

public final View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    // 通過 inflator 創建 view
    View view = createViewFromHackInflater(context, name, attrs);
    if (view == null) {
        view = createViewFromInflater(context, name, attrs);
    }
    // 根據 view 標簽創建 view
    if (view == null) {
        view = createViewFromTag(context, name, attrs);
    }
    // 處理 xml 中設置的點擊事件
    if (view != null) {
        checkOnClickListener(view, attrs);
    }
    return view;
}

private View createViewFromHackInflater(Context context, String name, AttributeSet attrs) {
    View view = null;
    for (SkinLayoutInflater inflater : SkinCompatManager.getInstance().getHookInflaters()) {
        view = inflater.createView(context, name, attrs);
        if (view == null) {
            continue;
        } else {
            break;
        }
    }
    return view;
}

private View createViewFromInflater(Context context, String name, AttributeSet attrs) {
    View view = null;
    for (SkinLayoutInflater inflater : SkinCompatManager.getInstance().getInflaters()) {
        view = inflater.createView(context, name, attrs);
        if (view == null) {
            continue;
        } else {
            break;
        }
    }
    return view;
}

public View createViewFromTag(Context context, String name, AttributeSet attrs) {
    // <view class="xxxx"> 形式的 tag,和 <xxxx> 一樣
    if ("view".equals(name)) {
        name = attrs.getAttributeValue(null, "class");
    }
    try {
        // 構造參數緩存
        mConstructorArgs[0] = context;
        mConstructorArgs[1] = attrs;
        if (-1 == name.indexOf('.')) {
            for (int i = 0; i < sClassPrefixList.length; i++) {
                // 通過構造方法創建 view
                final View view = createView(context, name, sClassPrefixList[i]);
                if (view != null) {
                    return view;
                }
            }
            return null;
        } else {
            return createView(context, name, null);
        }
    } catch (Exception e) {
        return null;
    } finally {
        mConstructorArgs[0] = null;
        mConstructorArgs[1] = null;
    }
}

這里用來創建 view 的 inflator 是通過 SkinCompatManager.getInstance().getInflaters() 獲取的。這樣設計的目的在于暴露接口給調用者,用來自定義控件的 inflator 邏輯。比如,針對三方控件和自定義控件的邏輯等。

該庫自帶的一個實現是,

public class SkinAppCompatViewInflater implements SkinLayoutInflater, SkinWrapper {
   @Override
    public View createView(Context context, String name, AttributeSet attrs) {
        View view = createViewFromFV(context, name, attrs);

        if (view == null) {
            view = createViewFromV7(context, name, attrs);
        }
        return view;
    }

    private View createViewFromFV(Context context, String name, AttributeSet attrs) {
        View view = null;
        if (name.contains(".")) {
            return null;
        }
        switch (name) {
            case "View":
                view = new SkinCompatView(context, attrs);
                break;
            case "LinearLayout":
                view = new SkinCompatLinearLayout(context, attrs);
                break;
            // ... 其他控件的實現邏輯
        }
    }
    // ...
}

可以看出實現的效果是根據要創建的標簽的名稱返回對應的包裝類。比如,View 返回 SkinCompatView 的實例。也就是,根據映射關系,將不支持換膚的布局控件在 inflate 的時候統一更換成支持換膚的。

4. 對該換膚方案的總結

跟前面兩個方案差不多,不過這個方案改動的東西挺多的。其主要邏輯是,自定義 view 加載邏輯,根據要創建的 view 類型使用對應的支持換膚的控件替換。當皮膚加載完畢之后會通知上述監聽的控件進行換膚操作。

整體而言,這種換膚方案的代價有些高,相當于對 view 全部做了 hook 替換。如果運行時發現錯誤也不容易排查。

2.5 換膚的其他方案

1. TG 的換膚邏輯

TG 的換膚只支持夜間和日間主題之間的切換,所以,相對上面幾種方案 TG 的換膚就簡單得多。

在閱讀 TG 的代碼的時候,我也 TG 在做頁面布局的時候做了一件很瘋狂的事情——他們沒有使用任何 xml 布局,所有布局都是通過 java 代碼實現的。

為了支持對主題的自定義 TG 把項目內幾乎所有的顏色分別定義了一個名稱,對以文本形式記錄到一個文件中,數量非常多,然后將其放到 assets 下面,應用內通過讀取這個資源文件來獲取各個控件的顏色。

2. 通過自定義控件 + 全局廣播實現換膚

這種方案根前面 hook LayoutInflator 的自動替換 view 的方案差不多。不過,這種方案不需要做 hook,而是對應用的內常用的控件全部做一邊自定義。自定義控件內部監聽換膚的事件。當自定義控件接收到換膚事件的時候,自定義控件內部觸發換膚邏輯。不過這種換膚的方案相對于上述通過 hook LayoutInflator 的方案而言,可控性更好一些。

全文總結

現在來看,Android 不論是使用 xml 的布局方式還是資源的加載方式,都有些過時和臃腫。對于資源的使用和加載的方式、style 和 theme 在 Android 中的處理,因為這些固有的布局邏輯的存在,導致想要做到布局和資源包的動態化非常困難。

竊以為,這里的 LayoutInflator 的加載 和 Hook Context Resources 邏輯還是非常有用的。我們可以結合上面的幾種方案,暢想一種新的實現換膚的方案:

  • 預定義應用中用得到的顏色和其他資源
  • 自定義 xml 屬性的 namespace 和鍵名稱,通過占位的形式指定值的名稱
  • 通過自定義 LayoutInflator 解析 xml 中 view 的屬性信息并構建映射關系
  • 通過加載 assets 或者外部文件中的鍵值對信息對 view 屬性動態更新和賦值

這篇文章是對 Android 應用的換膚方案的一些總結,也是為了后面對 Android 內的一些資源和換膚的動態化做一些理論的梳理。


https://mp.weixin.qq.com/s/OroOkzhFKle0jqc7NMi8dw

最多閱讀

簡化Android的UI開發 2年以前  |  515114次閱讀
Android 深色模式適配原理分析 1年以前  |  26416次閱讀
Android 樣式系統 | 主題背景覆蓋 1年以前  |  7953次閱讀
Android Studio 生成so文件 及調用 1年以前  |  5587次閱讀
30分鐘搭建一個android的私有Maven倉庫 3年以前  |  4751次閱讀
Android設計與開發工作流 2年以前  |  4413次閱讀
Google Enjarify:可代替dex2jar的dex反編譯 3年以前  |  4397次閱讀
Android多渠道打包工具:apptools 3年以前  |  4028次閱讀
移動端常見崩潰指標 2年以前  |  4014次閱讀
Google Java編程風格規范(中文版) 3年以前  |  3942次閱讀
Android-模塊化-面向接口編程 1年以前  |  3858次閱讀
Android內存異常機制(用戶空間)_NE 1年以前  |  3824次閱讀
Android UI基本技術點 3年以前  |  3790次閱讀
Android死鎖初探 2年以前  |  3734次閱讀

手機掃碼閱讀
18禁止午夜福利体验区,人与动人物xxxx毛片人与狍,色男人窝网站聚色窝
<蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>