对 Android 应用换肤方案的总结

发表于 1年以前  | 总阅读数:1787 次

虽然现在已经有很多不错的换肤方案,但是这些方案或多或少都存在自己的问题。在这篇文章中,我将对 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() 方法底层有防重入校验,所以,如果想要手动进行赋值,需要使用反射修改 mFactorySetmFactorymFactory2

那么 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 内的一些资源和换肤的动态化做一些理论的梳理。

本文由哈喽比特于1年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/OroOkzhFKle0jqc7NMi8dw

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:7月以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:7月以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:7月以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:7月以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:7月以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:7月以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:7月以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:7月以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:7月以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:7月以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:7月以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:7月以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:7月以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:7月以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:7月以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:7月以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:7月以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:7月以前  |  398次阅读  |  详细内容 »
 相关文章
简化Android的UI开发 4年以前  |  520698次阅读
Android 深色模式适配原理分析 3年以前  |  28623次阅读
Android阴影实现的几种方案 1年以前  |  10780次阅读
Android 样式系统 | 主题背景覆盖 3年以前  |  9583次阅读
 目录