Summary of Android application skinning solutions

Although there are many good skin resurfacing solutions now, these solutions have more or less problems of their own. In this article, I will sort out some of the existing dynamic skin-changing solutions for Android, analyze their underlying implementation principles, and then summarize the possibility of developing a new skin-changing solution.

1. Change skin through custom style

1.1 Basic principles of the plan

This solution is one that I have used more often before. I also made a lot of adjustments when using it.

Examples of application in "Ye Ye"

The way it is implemented is: the user customizes some themes in advance, and then when setting the theme, the ID corresponding to the theme is recorded in the local file. When the Activity RESUME, it is judged whether the current theme of the Activity is consistent with the theme. The previously set themes are consistent. If they are inconsistent, call the current Activity's recreate() method to rebuild.

In this solution, you can also predefine some attributes in the following way:

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

Then assign values ​​to these predefined properties using in your custom theme,

<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>

Finally, reference these custom properties in the layout file as follows:

<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"/>

The advantage of this reference method is that these custom properties can change dynamically as long as the theme is switched.

1.2 Summary of the plan

This solution requires restarting the activity after the skin change, which is a bit expensive. Especially when there are multiple nested fragments on the homepage, the state processing may be particularly complicated. For simple types of applications, this solution is a convenient and fast option.

2. Skin changing solution through hook LayoutInflater

2.1 How LayoutInflater works

The solution of skin changing through Hook LayoutInflater is a common one among many open source solutions. Before analyzing this solution, we'd better understand how LayoutInflater works.

Usually when we want to customize the Factory of Layout, we can call the following two methods to set our Factory to the system's 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);
        }
    }
    // ...
}

As can be seen from the above two methods,setFactory() has anti-reentrancy check at the bottom of the method. Therefore, if you want to manually assign values, you need to use reflection modification< a i=2>, and . mFactorySetmFactorymFactory2

So how to use mFactory and mFactory2?

When we call inflator to load the control from xml, we will go to the following code to actually perform the loading operation:

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;
    }
}

Let’s first look at the logic of creating a view through tag.

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;
}

It can be seen that mFactory2 is used first to create the view. If mFactory2 is empty, mFactory is used. Otherwise, mPrivateFactory is used to load the view. So, if we want to hook the view creation process, we should hook mFactory2 here. Because it has the highest priority.

Notice that there is no loop in the inflate method, so only the root layout can be loaded the first time. So how are the child controls in the root layout loaded? This uses the rInflateChildren method,

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();
    }
}

Notice that the createViewFromTag and rInflateChildren methods are called internally, that is to say, here through recursion is used to traverse the entire view tree, thereby loading the entire xml as a view tree.

The above is the logic of Android's LayoutInflater loading controls from xml. It can be seen that we can "monitor" the process of creating a view through hook mFactory2.

2.2 Android-Skin-Loader

1. Basic skin resurfacing process

After learning the underlying principle of Hook LayoutInflator, let's look at several skinning solutions based on this principle. The first is the Android-Skin-Loader library.

This library requires you to override Activity etc. Taking Activity as an example,

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);
    }

    // ...
}

It can be seen that the custom Factory is set to LayoutInflator. The implementation of custom LayoutInflater.Factory here is,

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();
            }
        }
    }
}

An xml attribute is customized here to specify whether to enable skinning configuration. Then during the process of creating the view, the attribute information of the view defined in xml is parsed, such as attributes such as background and textColor. And record its corresponding properties, property values ​​and controls in the cache in the form of mapping. When a skin change occurs, the property information of the control is updated in the code based on the mapping relationship here.

Take the attribute information of the background as an example and look at its apply operation.

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);
        }
    }
}

If the view is dynamically added, such as in java code, the library provides methods such as dynamicAddSkinEnableView to dynamically add mapping relationships to the cache.

Register to listen for skin-changing events (observer mode) in the activity's life cycle method.

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();
    }
    // ... 
}

When the skin is changed, the Activity will be notified and the onThemeUpdate() method will be triggered. The apply method of SkinInflaterFactory is called here. In the apply method of SkinInflaterFactory, the cached attribute information is traversed and updated to implement skin change.

2. Loading logic of skin package

The recording logic of the skin package is implemented through a customized AssetManager, which is similar to plug-in.

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);
}

Then use the following method when getting the value,

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. Several summaries of this solution

  • Skin change needs to inherit custom activity
  • Problems may occur when loading skin packages and APKs using resource obfuscation.
  • Does not handle the case where the attribute value is referenced in the form of ?attr
  • Each skin-changing attribute needs to be registered and implemented by yourself
  • Some properties of some controls may not provide corresponding java methods, so skinning in code will not work.
  • The use of style is not handled
  • Based on android.app.Activity, the version is too old
  • When the inflator creates a view, it actually only performs the interception and processing operations on the attributes. The operation of creating the view can be achieved through the factory of the proxy system.

2.3 ThemeSkinning

This library is developed based on the Android-Skin-Loader above, and has made many adjustments on its basis. Its address is ThemeSkinning

1. Implemented based on AppCompactActivity

This library is developed based on AppCompactActivity and 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();
    }

    // ...
}

At the same time, the library also provides methods to modify the status bar, although the capabilities are relatively limited. (When changing skins, you should also consider the adaptation of the status bar and bottom navigation bar)

2. Adjustment of 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 creation logic

Here we just collect the previous operations of creating View into a class.

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. Attribute parsing makes style compatible

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. Processing of fragments

Remove the specified View from the cache when the Fragment's life cycle method ends.

@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. Several summaries of this skin resurfacing solution

  • Compared with the first framework, it has been improved a lot
  • No need to differentiate between night themes

2.4 Android-skin-support

Compared with the above libraries, Android-skin-support has more stars and the code is more advanced (taking advantage of some new features).

1. Automatically register layoutinflator.factory based on activity lifecycle

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;
    }
    // ...
}

The logic of the LayoutInflaterCompat.setFactory2 method here is,

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. Implementation logic of 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 obtains the logic of view

In the above method, the logic of SkinCompatViewInflater obtaining view is as follows:

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;
    }
}

The inflator used to create the view here is obtained through SkinCompatManager.getInstance().getInflaters(). The purpose of this design is to expose the interface to the caller for customizing the inflator logic of the control. For example, the logic for third-party controls and custom controls, etc.

An implementation that comes with this library is,

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;
            // ... 其他控件的实现逻辑
        }
    }
    // ...
}

It can be seen that the effect of the implementation is to return the corresponding packaging class according to the name of the label to be created. For example, View returns an instance of SkinCompatView. That is, according to the mapping relationship, layout controls that do not support skinning are uniformly replaced with ones that support skinning when inflated.

4. Summary of this skin peeling solution

It’s similar to the previous two plans, but there are quite a few changes in this plan. The main logic is to customize the view loading logic and replace it with the corresponding control that supports skinning according to the type of view to be created. When the skin is loaded, the above-mentioned monitoring controls will be notified to perform the skin change operation.

Overall, the cost of this skin-changing solution is a bit high, which is equivalent to replacing the entire view with hooks. It is not easy to troubleshoot if errors are found during runtime.

2.5 Other options for skin resurfacing

1. TG skin-changing logic

TG's skin change only supports switching between night and day themes, so compared to the above solutions, TG's skin change is much simpler.

When reading TG's code, I also realized that TG did a very crazy thing when doing page layout - they did not use any xml layout, all layout was implemented through java code.

In order to support the customization of the theme, TG defines a name for almost all the colors in the project, and records them in text form into a file. The number is very large, and then puts it under assets. The application reads this resource file to obtain the color of each control.

2. Implement skin change through custom controls + global broadcast

This solution is similar to the previous solution of hooking LayoutInflator to automatically replace the view. However, this solution does not require hooks, but rather customizes all commonly used controls in the application. The custom control internally listens for skinning events. When the custom control receives the skinning event, the skinning logic is triggered internally in the custom control. However, this skin-changing solution is more controllable than the above-mentioned solution through hook LayoutInflator.

Full text summary

Looking at it now, Android is a bit outdated and bloated, whether it uses xml layout method or resource loading method. Regarding the way resources are used and loaded, and the processing of style and theme in Android, because of the existence of these inherent layout logic, it is very difficult to dynamize the layout and resource package.

I think the loading of LayoutInflator and Hook Context Resources logic here are still very useful. We can combine the above solutions to imagine a new solution for skin resurfacing:

  • Predefined colors and other resources used in applications
  • Customize the namespace and key name of the xml attribute, and specify the name of the value in the form of a placeholder
  • Use custom LayoutInflator to parse the attribute information of the view in xml and build a mapping relationship
  • Dynamically update and assign values ​​to view attributes by loading key-value pairs in assets or external files.

This article is a summary of the skin-changing solutions for Android applications, and it is also intended to do some theoretical sorting out some resources in Android and the dynamics of skin-changing.

おすすめ

転載: blog.csdn.net/Misdirection_XG/article/details/124697983#comments_28453648