换肤实现及LayoutInflater原理

2023-11-02

背景

不知道你在接到项目需求需要实现换肤功能时,有没有想过到底为什么需要换肤?虽然这是一篇技术文章,但我们不妨从产品和运营的角度想一想,实现换肤它究竟有什么样的价值?

在 Android 10 从系统层面已经为我们提供了深色主题(也就是夜间模式),它也可以认为是换肤的一种,官方文档对深色主题列举了以下优势:

  • 可大幅减少耗电量(具体取决于设备的屏幕技术)

  • 为弱视以及对强光敏感的用户提高可视性

  • 让所有人都可以在光线较暗的环境中更轻松地使用设备

系统提供给我们只有日间和夜间模式,从用户的角度它满足了在日间和夜间两种场景下更好的使用 app。

对于互联网公司的产品和运营的角度,这并不能满足需求,换肤的实现会更偏向于满足活动需要,比如在不同的活动节日时 app 可以切换为符合运营活动的皮肤贴合活动主题,让活动能有更好的宣传效果带来更多的利益。

本篇文章主要围绕插件化换肤讲解其实现和相关的原理。

实现换肤步骤

实现换肤,这里先给出实现步骤和结论:

  • 解析插件 apk 的包名

  • 获取插件 apk 的 Resources 对象

  • 控件使用资源,使用插件 apk 的包名和 Resources 对象获取指定名称的皮肤资源 id

解析插件 apk 的包信息

或许你会疑惑为什么需要获取插件 apk 的包信息,因为步骤 3 在获取插件 apk 的资源 id 时会使用到:

// name:资源名称。比如在 res/values/colors.xml 定义的颜色名称为 <color name="text_color">#FFFFFF</color>
// type:资源类型。如果是颜色资源则是 color,图片资源可能是 drawable 或 mipmap
// pkgName:包名
mResources.getIdentifier(name, type, pkgName);

其中参数 pkgName 就是插件 apk 的包名。需要注意的是,插件 apk 的包名不能与宿主 app 的包名相同(即插件 apk 的包名不能和要替换皮肤资源的 app 包名相同)

获取插件 apk 包名代码如下:

// skinPkgPath 是插件 apk 的文件路径
public String getSkinPackageName(String skinPkgPath) {
    PackageManager mPm = mAppContext.getPackageManager();
    PackageInfo info = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
    return info.packageName;
}

获取插件 apk 的 Resources 对象

获取插件 apk 的 Resources 对象的方式有两种,网上比较常见的是使用反射的方式,将插件 apk 的文件路径设置给隐藏方法 AssetManager.addAssetPath(skinPkgPath):

public Resources getSkinResources(String skinPkgPath) {
    try {
        AssetManager assetManager = AssetManager.class.newInstance();
        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
        addAssetPath.invoke(assetManager, skinPkgPath);

        Resources superRes = mAppContext.getResources();
        return new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}

另一种方式是通过 PackageManager.getResourcesForApplication(applicationInfo):

public Resources getSkinResources(String skinPkgPath) {
    try {
        PackageInfo packageInfo = mAppContext.getPackageManager().getPackageArchiveInfo(skinPkgPath, 0);
        packageInfo.applicationInfo.sourceDir = skinPkgPath;
        packageInfo.applicationInfo.publicSourceDir = skinPkgPath;
        Resources res = mAppContext.getPackageManager().getResourcesForApplication(packageInfo.applicationInfo);
        Resources superRes = mAppContext.getResources();
        return new Resources(res.getAssets(), superRes.getDisplayMetrics(), superRes.getConfiguration());
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}

替换资源

通过上面的步骤获取了插件 apk 的包名和 Resources 对象,使用它们获取对应资源名称的资源 id:

public Drawable getSkinDrawable(Context context, int resId) {
	int targetResId = getTargetResId(context, resId);
	if (targetResId != 0) {
		// mResources 是插件 apk 的 Resources 对象
		return mResources.getDrawable(targetResId);
	}
	return context.getResources().getDrawable(resId);
}

public int getSkinColor(Context context, int resId) {
	int targetResId = getTargetResId(context, resId);
	if (targetResId != 0) {
		// mResources 是插件 apk 的 Resources 对象
		return mResources.getColor(targetResId);
	}
	return context.getResources().getColor(resId);
}

// 其他资源获取同理
...

public int getTargetResId(Context context, int resId) {
	try {
		// 根据资源 id 获取资源名称
		String name = context.getResources().getResourceEntryName(resId);
		// 根据资源 id 获取资源类型
		String type = context.getResources().getResourceTypeName(resId);
		// 获取插件 apk 对应资源名称的资源,mResources 和 mSkinPkgName 分别是插件 apk 的Resources 对象和包名
		return mResources.getIdentifier(name, type, mSkinPkgName);
	} catch (Exception e) {
		return 0;
	}
}

简单的插件化换肤实现和存在的问题

根据上述三个步骤,下面简单的实现一个插件化换肤的 demo。直接上代码:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <ImageView
        android:id="@+id/image_view"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="@drawable/test"
        tools:ignore="ContentDescription" />

    <Button
        android:id="@+id/btn_replace"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="replace"
        android:textAllCaps="false" />
</LinearLayout>

public class MainActivity extends AppCompatActivity {

    private String mSkinApkPath;
    private String mSkinPackageName;
    private Resources mSkinResources;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ActivityMainBinding binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        binding.btnReplace.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (TextUtils.isEmpty(mSkinApkPath)) {
                    mSkinApkPath = getSkinApkPath();
                }
                if (TextUtils.isEmpty(mSkinPackageName)) {
                    mSkinPackageName = getSkinPackageName(mSkinApkPath);
                }
                if (mSkinResources == null) {
                    mSkinResources = getSkinResources(mSkinApkPath);
                }
                // 替换的资源 id 为 R.drawable.test
                int targetResId = getTargetResId(mSkinResources, mSkinPackageName, R.drawable.test1);
                if (targetResId != 0) {
                    Drawable drawable = mSkinResources.getDrawable(targetResId);
                    if (drawable != null) {
                        binding.imageView.setBackground(drawable);
                    }
                }
            }
        });
    }

    private String getSkinApkPath() {
        File skinApkDir = new File(getCacheDir(), "skin");
        if (!skinApkDir.exists()) {
            skinApkDir.mkdirs();
        }
        File skinApkFile = new File(skinApkDir + File.separator + "skin.zip");
        // 实际项目一般是通过网络下载插件 apk 文件
        // 这里是将插件 apk 放在 assets 目录
        try(BufferedSource sourceBuffer = Okio.buffer(Okio.source(getAssets().open("skin.zip")));
            BufferedSink sinkBuffer = Okio.buffer(Okio.sink(skinApkFile))) {
            sinkBuffer.write(sourceBuffer.readByteArray());
        } catch (IOException e) {
            e.printStackTrace();
        }

        if (!skinApkFile.exists()) {
            return null;
        }

        return skinApkFile.getAbsolutePath();
    }

    // 获取插件 apk 包名即 com.example.skin.child
    private String getSkinPackageName(String skinApkPath) {
        if (TextUtils.isEmpty(skinApkPath)) return null;

        PackageManager mPm = getPackageManager();
        PackageInfo info = mPm.getPackageArchiveInfo(skinApkPath, PackageManager.GET_ACTIVITIES);
        return info.packageName;
    }

    // 获取插件 apk 的 Resources
    private Resources getSkinResources(String skinApkPath) {
        if (TextUtils.isEmpty(skinApkPath)) return null;

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

            Resources superRes = getResources();
            return new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    // 根据 id 查找到资源名称和类型,再使用插件 apk 的 Resources 查找对应资源名称的 id
    private int getTargetResId(Resources skinResources, String skinPackageName, int resId) {
        if (mSkinResources == null || TextUtils.isEmpty(skinPackageName) || resId == 0) return 0;

        try {
            String resName = getResources().getResourceEntryName(resId);
            String type = getResources().getResourceTypeName(resId);
            return skinResources.getIdentifier(resName, type, skinPackageName);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return 0;
    }
}

在这里插入图片描述
代码实现比较简单,ImageView 在 xml 布局文件设置了一个图片背景 R.drawable.test,当点击按钮时会读取装载插件 apk 的包信息和 Resources,替换插件 apk 指定名称的资源。

上面的代码同样也是插件化换肤的核心代码。

运用在实际项目需要考虑和解决几个问题。

换肤如何动态刷新?

从产品的角度考虑,换肤的实现最直观的方式就是能够一键换肤,根据选择的皮肤可以快速的动态刷新界面并展示换肤后的效果。

需要注意的是,被通知刷新的界面不仅仅是当前页面,而是所有页面,在后台的页面和新跳转的页面也能跟随皮肤切换

或许你会想到 让页面重建或 app 重启的方式,这虽然可行,但是并不可取。页面重建意味着页面状态丢失,页面的重建会带给用户比较糟糕的体验;为了祢补这个问题,对每个页面都追加保存状态,即在 onSaveInstanceState() 保存状态,但这将会是巨大的工作量。

一个可行的方式是,通过 registerActivityLifecycleCallbacks() 监听每一个 Activity 页面,当操作完换肤后返回时,在 onActivityResumed() 获取到对应的界面刷新 View 换肤

// SkinObserver 会去 mFactoryMap 获取 LayoutInflater.Factory2 尝试刷新
private WeakHashMap<Context, SkinObserver> mSkinObserverMap;
private WeakHashMap<Context, LayoutFactory.Factory2> mFactoryMap;

application.registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
	@Override
	public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
		// getLayoutFactory2() 返回 LayoutInflater.Factory2 实现类
		// 记录所有需要换肤的 View
		LayoutInflater inflater = LayoutInflater.from(activity);
		inflater.setFactory2(getLayoutFactory2(activity));
	}
	
	@Override
	public void onActivityResumed(Activity activity) {
		// 以 activity 为 key,获取对应界面的 LayoutInflater.Factory2
		// 界面可见时尝试通知 View 刷新,主要处理从其他位置操作换肤返回后及时刷新换肤效果
		SkinObserver observer = getObserver(activity);
		observer.updateSkinIfNeed();
	}

	@Override
	public void onActivityDestroyed(@NonNull Activity activity) {
		// 销毁监听
		mSkinObserverMap.remove(activity);
		mFactoryMap.remove(activity);
	}
});

控件换肤刷新的性能考虑

大部分情况下换肤并不需要将界面所有的 View 更新并且只更新 View 的部分属性,我们并不希望替换皮肤时 View 的所有属性重新被渲染刷新,这样能最好的做到减少性能损耗。所以换肤刷新可以有以下优化方向:

  • 只更新需要换肤的 View

  • 需要换肤的 View 只需要更新部分指定的属性。比如 ImageView 可能只需要更新一个 drawable 背景,TextView 只需要更新 textColor 文字颜色

因为 TextView 或 ImageView 等都是 Android 提供的控件,我们无法直接修改它们的内部代码,只实现更新部分属性的需求首先我们需要自定义 View:

public class SkinTextView extends TextView {
	...
}

public class SkinImageView extends ImageView {
	...
}

为了能在换肤时,收到通知的界面控件能统一处理,自定义 View 可以统一实现自定义的接口 ISkinUpdater,处理接收到通知的 View 能处理更新属性:

public interface ISkinUpdater {
	void updateSkin();
}

public class SkinTextView extends AppCompatTextView implements ISkinUpdater {
	public void updateSkin() {
		// 根据插件 apk 的 Resources 更新 textColor
	}
}

public class SkinImageView extends AppCompatImageView implements ISkinUpdater {
	public void updateSkin() {
		// 根据插件 apk 的 Resources 更新 background
	}
}

如何降低 xml 布局中 View 的替换成本

根据上面的设计思路,我们需要将所有需要换肤的 View 都替换为实现了 ISkinUpdater 接口的 View:

<!-- 替换前 -->
<TextView
	android:layout_width="wrap_content"
	android:layout_height="wrap_content"
	android:text="Hello World"
	android:textColor="@color/skin_color" />

<!-- 替换后 -->
<com.example.skin.widget.SkinTextView
	android:layout_width="wrap_content"
	android:layout_height="wrap_content"
	android:text="Hello World"
	android:textColor="@color/skin_color" />

如果项目界面很多,这将是一个较大的工作量,而且哪一天需要剔除或者替换换肤库,这无异于进行一次重构。

所以我们还需要解决如何最低成本的完成 View 的替换,又不需要手动修改 xml 布局已经定义好的 View。

LayoutInflater 是在开发中经常接触到的可以将 xml 布局转换为 View 的工具,xml 可以解析为 View,是否可以通过它在解析 View 时干扰并修改为我们需要的自定义 View?

如果有了解过 LayoutInflater,查看其源码就可以发现 Android 已经为我们提供了 hook。为了更好的理解,下面简单介绍下 LayoutInflater 的原理。

LayoutInflater 原理

当我们在 xml 文件布局中定义了一个 TextView 或 ImageView 时,最终日志打印输出的控件变成了 AppCompatTextView(或 MaterialTextView) 和 AppCompatImageView:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <ImageView
        android:id="@+id/image_view"
        android:layout_width="100dp"
        android:layout_height="100dp"
        tools:ignore="ContentDescription" />
</LinearLayout>

输出结果:
2021-08-29 13:30:39.805 12506-12506/com.example.skin I/MainActivity: textView = com.google.android.material.textview.MaterialTextView{7ff63ad V.ED..... ......ID 0,0-0,0 #7f0801ab app:id/text_view}
2021-08-29 13:30:39.806 12506-12506/com.example.skin I/MainActivity: imageView = androidx.appcompat.widget.AppCompatImageView{cea56e2 V.ED..... ......I. 0,0-0,0 #7f0800c5 app:id/image_view}

它是怎么做到的?

在 xml 布局定义的控件最终都会通过 LayoutInflater.inflate() 创建 View 对象。具体分析下 LayoutInflater 的原理。

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
    return inflate(resource, root, root != null);
}

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
    final Resources res = getContext().getResources();
    ...

	// 获取 xml 解析器
    XmlResourceParser parser = res.getLayout(resource);
    try {
    	// 解析 xml
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}

在使用 LayoutInflater.from(context).inflate(layoutId, parent, attachToRoot) 时会需要传入三个参数,除了 layoutId 还有 root 和 attachToRoot,这两个参数的用处是什么?同样带着这个问题接着分析源码。

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 {
			final String name = parser.getName();
			
			// 如果是 <merge /> 标签
			if (TAG_MERGE.equals(name)) {
                if (root == null || !attachToRoot) {
                    throw new InflateException("<merge /> can be used only with a valid "
                            + "ViewGroup root and attachToRoot=true");
                }

                rInflate(parser, root, inflaterContext, attrs, false);
            } else {
                // 创建 view
                final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                ViewGroup.LayoutParams params = null;

                if (root != null) {
                    // 创建 root 的 LayoutParams
                    params = root.generateLayoutParams(attrs);
                    if (!attachToRoot) {
                        // 布局创建的 view 使用 root 的 LayoutParams
                        temp.setLayoutParams(params);
                    }
                }

                // 创建子 view
                rInflateChildren(parser, temp, attrs, true);

                // 如果 root != null && attachToRoot=true
                // 布局的 view 会添加到指定的 root
                if (root != null && attachToRoot) {
                    root.addView(temp, params);
                }

                // 如果 root == null || attachToRoot=false
                // 布局的 view 就是顶层的 view
                if (root == null || !attachToRoot) {
                    result = temp;
                }
            }
		} catch (...) {
			...
		}

		return result;
    }
}

根据上面的源码分析,inflate() 传入的 root 和 attachToRoot 参数的作用如下:

  • 当 root != null && attachToRoot=true,xml 布局解析创建的顶层 View 会添加到指定的 root,并使用 root 的 LayoutParams,最终返回 root

  • 当 root == null || attachToRoot=false,xml 布局解析创建的顶层 View 就是最终返回的 view

根据 root 的 attachToRoot 的数值具体可以分别以下几种情况:

  • root == null && attachToRoot=false:xml 布局解析创建的顶层 View 就是最终返回的 View

  • root != null && attachToRoot=false:xml 布局解析创建的顶层 View 就是最终返回的 View,该 View 使用 root 的 LayoutParams

  • root == null && attachToRoot=true:xml 布局解析创建的顶层 View 就是最终返回的 View

  • root != null && attachToRoot=true:xml 布局解析创建的顶层 View 会被添加到 root 作为子 View,并使用 root 的 LayoutParams

继续分析创建 View 的方法 createViewFromTag():

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

    // Apply a theme wrapper, if allowed and one is specified.
    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 {
		if (name.equals(TAG_1995)) {
			// Let's party like it's 1995!
			return new BlinkLayout(context, attrs);
		}

		View view;
		if (mFactory2 != null) {
			// 如果 LayoutInflater.Factory2 != null,优先使用它创建 view
			view = mFactory2.onCreateView(parent, name, context, attrs);
		} else if (mFactory != null) {
			// 如果 LayoutInflater.Factory != null,使用它创建 view
			view = mFactory.onCreateView(name, context, attrs);
		} else {
			view = null;	
		}
	
		if (view == null && mPrivateFactory != null) {
			view = mPrivateFactory.onCreateView(parent, name, context, attrs);
		}

        if (view == null) {
            final Object lastContext = mConstructorArgs[0];
            mConstructorArgs[0] = context;
            try {
            	// LayoutInflater.Factory2 和 LayoutInflater.Factory 都没有创建 view,通过反射创建 view
                if (-1 == name.indexOf('.')) {
                    view = onCreateView(context, parent, name, attrs);
                } else {
                    view = createView(context, name, null, attrs);
                }
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        }

        return view;
    } catch (...) {
       ...
    } 
}

createViewFromTag() 分为三个步骤创建 View:

  • 优先判断 LayoutInflater.Factory2 != null,使用它创建 View

  • 判断 LayoutInflater.Factory != null,使用它创建 View

  • 如果 LayoutInflater.Factory2 和 LayoutInflater.Factory 都没有创建 View,通过反射创建 View

其中变量 mFactory2 和 mFactory 就是 LayoutInflater.Factory2 和 LayoutInflater.Factory,系统提供了创建 View 的 hook 接口,根据需要可以提供 LayoutInflater.Factory2 优先于系统自定义创建 View

上面提到的 AppCompatTextView 和 AppCompatImageView 等兼容控件 Android 也是通过 hook 的方式实现替换:

class AppCompatDelegateImpl extends AppCompatDelegate 
	implementation MenuBuilder.Callback, LayoutInflater.Factory2 {
	...

	@Override
	public View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs) {
		...
		
		return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
                IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
                true, /* Read read app:theme as a fallback at all times for legacy reasons */
                VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
        );
	}
}

public class AppCompatViewInflater {
	...

	final View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs, boolean inheritContext,
            boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
		...
		
		switch(name) {
			// 将 TextView 替换为 AppCompatTextView
			case "TextView":
				view = new AppCompatTextView(context, attrs);
				break;
			...
		}
	} 
}

跟踪源码可以知道会在 Activity 的 onCreate() 时将 LayoutInflater.Factory2 赋值:

public class AppCompatActivity extends FragmentActivity implements AppCompatCallback,
        TaskStackBuilder.SupportParentable, ActionBarDrawerToggle.DelegateProvider {
	...

	@Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        final AppCompatDelegate delegate = getDelegate();
        delegate.installViewFactory(); // 设置 LayoutInflater.Factory2
        delegate.onCreate(savedInstanceState);
        super.onCreate(savedInstanceState);
    }
}

class AppCompatDelegateImpl extends AppCompatDelegate
        implements MenuBuilder.Callback, LayoutInflater.Factory2 {
	...
	
	@Override
    public void installViewFactory() {
        LayoutInflater layoutInflater = LayoutInflater.from(mContext);
        if (layoutInflater.getFactory() == null) {
            LayoutInflaterCompat.setFactory2(layoutInflater, this);
        } else {
            if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
                Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
                        + " so we can not install AppCompat's");
            }
        }
    }
}

具体流程图如下:

在这里插入图片描述

LayoutInflater.Factory2 替换 View

根据上面源码分析 Android 提供了 LayoutInflater.Factory2 支持开发者自定义创建 View。具体代码如下:

public class MyFactory2 implements LayoutInflater.Factory2 {
	private SkinViewInflater mSkinViewInflater;
	// 临时存储每个界面的 View,方便返回时通知可见界面更新换肤
	private final List<WeakReference<ISkinUpdater>> mSkinUpdaters = new ArrayList<>();

	@Overrice
	public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
		// 自定义创建 View
		View view = createView(context, name, attrs);
		// 如果没有创建 View,返回 null 最后会通过反射创建 View
		if (view == null) {
			return null;
		}
		if (view instanceOf ISkinUpdater) {
			mSkinUpdaters.add(new WeakReference<>((ISkinUpdater) view)));
		}
		return view;
	}

	@Override
	public View onCreateView(String name, Context context, AttributeSet attrs) {
		// 自定义创建 View
		View view = createView(context, name, attrs);
		// 如果没有创建 View,返回 null 最后会通过反射创建 View
		if (view == null) {
			return null;
		}
		if (view instanceOf ISkinUpdater) {
			mSkinUpdaters.add(new WeakReference<>((ISkinUpdater) view)));
		}
		return view;
	}

	private View createView(Context context, String name, AttributeSet attrs) {
		if (mSkinViewInflater == null) {
			mSkinViewInflater = new SkinViewInflater();
		}
		...
		return mSkinViewInflater.createView(parent, name, context, attrs);
	}
	
	// 通知更新 View 换肤(替换资源)
	public void updateSkin() {
		if (!mSkinUpdaters.isEmpty()) {
			for (WeakReference ref : mSkinUpdaters) {
				if (ref != null && ref.get() != null) {
					((ISkinUpdater) ref.get()).updateSkin();
				}
			}
		}
	}
}

public class SkinViewInflater {
	
	public final View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) {
		View view = null;
		// 自定义 inflater 创建 view
		if (view == null) {
			view = createViewFromInflater(context, name, attrs);
		}
		// 反射创建 view
		if (view == null) {
			view = createViewFromTag(context, name, attrs);
		}
		...
		return view;
	}
	
	private View createViewFromInflater(Context context, String name, AttributeSet attrs) {
		View view = null;
		if (name.contains(".")) {
			return null;	
		}
		switch (name) {
			case "View":
				view = new SkinView(context, attrs);
				break;
			case "TextView":
				view = new SkinTextView(context, attrs);
				break;	
			....		
		}
		return view;
	}
}

在上面小节有提到通过 registerActivityLifecycleCallbacks() 通知界面刷新,代码如下:

public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
	private static volatile SkinActivityLifecycle sInstance = null;

	public static SkinActivityLifecycle init(Application application) {
		if (sInstance == null) {
			synchronized(SkinActivityLifecycle.class) {
				if (sInstance == null) {
					sInstance = new SkinActivityLifecycle(application);
				}	
			}
		}
		return sInstance;
	}
	
	private SkinActivityLifecycle(Application application) {
		// 注册监听每个界面
		application.registerActivityLifecycleCallbacks(this);
		installLayoutFactory(application);	
	}
	
	@Override
	public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
		// activity 创建时设置 LayoutInflater.Factory2 创建 View
		installLayoutFactory(activity); 
	}
	
	@Override
	public void onActivityResumed(Activity activity) {
		// 界面返回刷新可见页面换肤
	}
	
	@Override
	public void onActivityDestroyed(Activity activity) {
		// 移除 activity 防止内存泄漏
	}
	
	... // 其他生命周期监听
	
	private void installLayoutFactory(Context context) {
		LayoutInflater inflater = LayoutInflater.from(context);
		inflater.setFactory2(new MyFactory2()); 
	}
}

小结

上面的换肤方案其实就是开源库 Android-skin-support 的原理,整体流程图如下:

在这里插入图片描述

多线程 inflate 存在的隐患

当你的项目所有的界面创建全部都运行在主线程时,上面的架构设计并无问题且运行良好。但项目中如果有为了启动性能优化,会在异步子线程处理 inflate (例如使用 AsyncLayoutInflater 或自定义的异步布局加载框架,将布局 inflate 切换到子线程执行),将会存在线程安全隐患。
在这里插入图片描述
上图是线上遇到的大量且类似的 ClassCastException。

线上该问题出现解决难点主要有两个:

  • adapter 加载的列表布局并没有 ImageView,那么 ImageView 是哪里来的?ImageView 在其他的布局,不同的布局为什么会被干涉?

  • 难以复现,多线程问题会因为设备硬件或软件等因素导致很难复现

通过多日的压测和日志打印,最终定位到 SkinViewInflater 的 静态成员变量 sConstructorMap 通过 name 获取构造函数时出现了问题:

public class SkinViewInflater {
	...
	// 根据控件名称存储构造函数,相同的控件复用同一个 Constructor 以达到优化性能的目的
	private static final Map<String, Constructor<? extends View>> sConstructorMap
            = new ArrayMap<>();

	private View createView(Context context, String name, String prefix)
            throws ClassNotFoundException, InflateException {
        // 通过控件 name 获取缓存的构造函数
		Constructor<? extends View> constructor = sConstructorMap.get(name);

		Log.w("createView", "name:", name, ",constructor:", constructor, "context:", context);
        try {
            if (constructor == null) {
                Class<? extends View> clazz = context.getClassLoader().loadClass(
                        prefix != null ? (prefix + name) : name).asSubclass(View.class);

                constructor = clazz.getConstructor(sConstructorSignature);
                sConstructorMap.put(name, constructor);
            }
            constructor.setAccessible(true);
            // 通过 map 获取了错误的构造函数,导致反射创建时出现 ClassCastException
            return constructor.newInstance(mConstructorArgs);
        } catch (Exception e) {
            return null;
        }
	}
}

当多个线程一起执行 inflate 创建 View 的操作,非线程安全情况下 map 的数据可能被覆盖污染,最终就会导致获取的 constructor 构造函数不正确引发 ClassCastException

上述线程安全问题可以修改两个地方规避解决:

  • LayoutInflater.Factory2 接管创建 View 的流程,在 hook 创建的地方添加类锁保证线程安全

  • 更进一步保证通过 name 获取的 constructor 不被覆盖污染,sConstructorMap 修改为线程安全的 ConcurrentHashMap

public class MyFactory2 implements LayoutInflater.Factory2 {
	
	@Override
	public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
		// 添加类锁,确保线程安全问题
		synchronized(MyFactory2.class) {
			View view = createView(parent, name, context, attrs);
			...
		}
	}
	
	@Override
	public View onCreateView(String name, Context context, AttributeSet attrs) {
		// 添加类锁,确保线程安全问题
		synchronized(MyFactory2.class) {
			View view = createView(null, name, context, attrs);
			...
		}
	}
}

public class SkinViewInflater {
	...
	// 使用 ConcurrentHashMap 支持多线程并发处理情况
	private static final Map<String, Constructor<? extends View>> sConstructorMap
            = new ConcurrentHashMap<>();
	...
}

虽然上述处理能解决线程安全问题,但是该解决方案在一定程度上是会影响主线程创建控件的性能和响应

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

换肤实现及LayoutInflater原理 的相关文章

  • 在 Android 上将视频设置为壁纸

    我想知道如何将视频设置为壁纸 否则不可能 我可以将图像设置为壁纸 并且可以构建动态壁纸 但无法将视频设置为壁纸 所以有人知道我该怎么做吗 提前致谢 我认为唯一可以做到的方法是将其合并到 动态壁纸 中 缺点是正如其他人提到的那样 这会严重影响
  • 致命异常:Google 地图 V2 中的 GLThread、StackOverflowError

    我正在实施 Google 地图 V2 并利用从外部 GPX 文件接收的交付路线 设备路径 设备当前位置和交付点位置 问题是它大多数时候都有效 我收到的错误 当它不起作用时 是下一个 03 16 20 48 37 811 I dalvikvm
  • getItemAtPosition() 未在列表视图中返回值

    我创建了一个自定义基本适配器类 用图像和文本填充列表视图 类的代码如下 public class ViewAdapter extends BaseAdapter private Activity activity private Strin
  • 检测正在插入的设备

    我希望能够检测设备是否已插入 我希望能够像查询连接状态一样进行查询 这可能吗 或者我是否需要创建一个监听电池事件的广播接收器 显然是ACTION BATTERY CHANGED http developer android com refe
  • Android-ListView-performItemClick

    当我尝试使用时遇到一些困难执行项目单击ListView 的功能 我想要做的就是以编程方式在列表的第一项中执行单击 我怎样才能做到这一点 我在文档中查找了该函数 但我并不真正理解它的参数 我尝试过类似的事情 myListView perfor
  • 强制关闭导致HTTP实体可能不为空

    这里是发送数据 Http 的完整代码 asynctask private class MyAsyncTaskPupuk extends AsyncTask
  • Android异步服务调用策略

    这是场景 客户端对服务进行远程调用 返回 void 并提供 回调对象 服务在后台线程上执行一些长时间运行的逻辑 然后使用回调对象来触发以太成功或失败 因为这些操作视觉元素 执行 Activity runOnUiThread 块 该场景运行良
  • 带有内容提供商的小部件;无法使用ReadPermission?

    所以我刚刚为我的应用程序实现了一个小部件 它通过我的数据库从数据库获取数据ContentProvider 我在清单中定义了自己的读 写权限 声明我使用它们 似乎没有什么区别 并在内容提供程序中要求它们
  • 在应用程序之间共享自定义帐户验证器

    我有一个为使用自定义 AccountAuthenticator 的客户端构建的应用程序 它工作得非常好 并且满足了客户的需求 但是 这只是将使用相同身份验证管理器的应用程序集合中的第一个应用程序 这就是我不确定如何继续的地方 我无法知道任何
  • 无法在 Android 中使用自定义数组适配器进行搜索?

    我无法从以下位置搜索listview 我尝试了各种方法 但它对我不起作用 没有错误 我有其他方式进行搜索 但我想让这种方式成为可能 这是代码 public class MainActivity extends Activity implem
  • android device.getUuids 返回 null

    我正在尝试使用低功耗蓝牙 BLE 通过 Android 应用程序连接到 Arduino Uno 我正在 Android Studio 上进行开发 使用 Samsung Galaxy S4 和 Android 版本 5 0 1 进行测试我点击
  • 为什么 Android 上的免安装应用有两种设置?

    我使用的是运行 Android 11 的 Pixel 3 我发现有 2 种不同的设置可以控制免安装应用的某些方面 设置 应用程序和通知 默认应用程序 打开链接 即时应用程序 即使未安装 也打开应用程序中的链接 切换默认为开 Google P
  • Android Studio错误的含义:未注释的参数覆盖@NonNull参数

    我正在尝试 Android Studio 创建新项目并添加默认值后onSaveInstanceState方法创建 MyActivity 类 当我尝试将代码提交到 Git 时 我收到一个我不明白的奇怪错误 代码是这样的 我得到的错误是这样的
  • 测试应用内结算:“发布者无法购买此商品”

    我的应用程序似乎已准备好在我的设备上进行应用内购买程序的 现实生活 测试 但是 我在 Play 商店中收到 发布商无法购买此商品 的错误消息 现在 我应该如何测试这个 我不想通过仅用于测试的虚拟帐户重新安装手机来丢失手机的配置 在开发者控制
  • 安卓独立包

    我有一个很大的 UI 大约 20 25 个屏幕 我应该如何组织我的代码 我应该按功能分成不同的包吗 我是否应该为所有 UI 类创建一个包 然后创建子包进行组织 或者我不应该创建单独的包并组织到文件夹中 任何帮助将不胜感激 当您创建文件夹时
  • 如何在 Android 中不使用 Intent 裁剪图像

    我正在尝试裁剪图像我使用了下面的代码 意图 i new Intent Intent ACTION PICK android provider MediaStore Images Media EXTERNAL CONTENT URI i pu
  • 使用Android Camera API,拍摄照片的方向始终未定义

    我使用相机API 拍摄的照片总是旋转90度 我想旋转它 所以首先我想知道图片的方向 这一点我被卡住了 我总是以两种方式得到未定义的方向 这是代码 Override public void onPictureTaken byte data C
  • 如何为部分 Android 活动创建通用代码?

    我的申请中有 14 项活动 这 9 个活动中包含自定义标题栏和选项卡窗格 所以在这里我需要在一个地方编写这个通用代码 而不是在每个包含自定义标题栏和选项卡窗格代码的活动中编写冗余代码 即布局及其活动特定代码 有哪些可能的方法可以做到这一点
  • 活动构建变体没有测试工件

    我基于 调试 构建变体创建了一个名为 bitrise 的新构建类型 使用 debug 构建变体时 经过检测的 androidTests 构建并运行良好 但是当我切换到新的 bitrise 构建变体时 出现以下错误 Process finis
  • 在android中测量不规则多边形的面积

    我正在开发一个应用程序 在其中我在地图上绘制多边形 并且我使用的地图不是谷歌 它的Mapsforge开源离线地图库 我可以通过将地理点转换为像素点来轻松在地图上绘制多边形 但在这里我想发现是不规则的多边形 为此我做了很多尝试 但它让我失败了

随机推荐

  • 35黑马QT笔记之QFile写文件

    35黑马QT笔记之QFile写文件 1 如何在文本编辑区写内容保存到一个本地文件呢 1 利用文件对话框函数getSaveFileName获取要创建的文件路径 实际上还没真正在电脑创建 只是意味着你要创建的路径 2 将要创建的文件路径与QFi
  • python判断字符为空_大神教你如何判断Python中字符串是否为空和null

    导读 这篇文章主要介绍了Python判断字符串是否为空和null 文中通过示例代码介绍的非常详细 对大家的学习或者工作具有一定的参考学习价值 需要的朋友可以参考下 判断python中的一个字符串是否为空 可以使用如下方法 1 使用字符串长度
  • msvcp120.dll丢失的解决方法?哪种方法更推荐

    msvcp120 dll是一个Windows操作系统的动态链接库文件 它属于Microsoft Visual C Redistributable软件包的一部分 这个文件包含了一些用于C 程序编译和运行的函数和类 当某个程序需要使用这些函数和
  • [错误解决]centos中使用kubeadm方式搭建多master的高可用K8S集群

    安装步骤 部署Kubernetes Master时的错误 部署Kubernetes Master时 创建了一个kubeadm config yaml文件 将相关配置信息放到这个地方 该文件如下 apiServer certSANs mast
  • Spring学习笔记:Bean的装配方式

    学习内容 Bean的装配方式 文章目录 学习内容 Bean的装配方式 1 装配Bean的概述 2 基于XML的装配 3 基于注解的装配 4 自动装配 5 使用注解实现自动装配 6 使用Java的方式配置Spring 1 装配Bean的概述
  • 【SLAM】——DynaSLAM项目环境配置(超多坑)

    DynaSLAM 坑多 慢慢来 不要急 先整体说一下 项目是在ORB SLAM2项目的基础上 加上maskrcnn的融合 主流程采用还是采用ORB SLAM2的流程 maskrcnn部分采用c 调用python的实现 其中又穿插opencv
  • 计算机网络的体系结构

    1 OSI 七层模型 提出者 ISO 国际标准化组织 一种网络分层的设计方法论 比较复杂且不实用 落地时几乎都是TCP IP五层模型 层数 功能 数据传输单元 7 应用层 面向用户 应用程序 6 表示层 处理在两个通信系统中交换信息的表示方
  • 医学图像处理综述

    本文作者 张伟 公众号 计算机视觉life 编辑成员 0 引言 医学图像处理的对象是各种不同成像机理的医学影像 临床广泛使用的医学成像种类主要有X 射线成像 X CT 核磁共振成像 MRI 核医学成像 NMI 和超声波成像 UI 四类 在目
  • 2020 蓝桥杯省赛 B 组模拟赛:寻找重复项

    include
  • 期货和股票平仓时成本计价的区别

    期货和股票平仓时成本计价的区别 期货交易采用的是当天无负债结算 在掌握持仓盈亏之前 你先要掌握一个概念 结算价 结算价是指对当天未平仓合约进行交易保证金结算和盈亏结算的基准价 它是把期货合约当天的各个成交价格按照成交量进行加权平均得来的 当
  • codeforces 1217b B - Zmei Gorynich

    题意 有头龙有m个头 有n种砍法 第i种 砍去ai个 再长bi个 某一时刻头为0胜利 要砍几刀 先找伤害最大 记为d 的刀和效率 maxv max ai bi 最高的刀 如果d
  • 利用Eclipse进行重构

    来源 http my opera com jojomclntosh blog 重构和单元测试是程序员的两大法宝 他们的作用就像空气和水对于人一样 平凡 不起眼 但是意义深重 预善事 必先利器 本文就介绍怎样在Eclipse中进行重构 本文介
  • 为什么大家都在抵制用定时任务实现「关闭超时订单」功能?

    作者 阿Q 来源 阿Q说代码 前几天领导突然宣布几年前停用的电商项目又重新启动了 让我把代码重构下进行升级 让我最深恶痛觉的就是里边竟然用定时任务实现了 关闭超时订单 的功能 现在想来 哭笑不得 我们先分析一波为什么大家都在抵制用定时任务来
  • STL容器去重,Vector、list等

    前言 容器在实际中用的还是比较多的 比如vector list map等 所以难免遇到需要排序的情况 也就随之会遇到去重的问题 当然 自己循环遍历也能解决问题 这里介绍的是使用标准库算法 思路 1 对容器排序 使用 sort 方法 2 un
  • break与continue(跳转语句)

    break 完全中止循环 重点是跳出循环 continue 直接跳到循环的下一次迭代 从当前位置跳出循环 在当前循环进行下一次循环 重点 都是对循环进行跳转 注 虚线表示不执行 break while 条件表达式1 if 条件表达式2 语句
  • Ubuntu18.04更换国内源

    Ubuntu本身的源使用的是国内的源 下载速度比较慢 不像CentOS一样yum安装的时候对镜像站点进项选择 所以选择了更换成国内的源 以下内容整合自网络 备份 etc apt sources list文件 mv etc apt sourc
  • 《让子弹飞》向我们展现真实的革命

    作者 朱金灿来源 http blog csdn net clever101 让子弹飞 是一部有内涵的电影 有内涵就意味着有很多话题可以谈 对于革命 热血青年受革命题材的电影 电视剧的影响对革命都怀有玫瑰色的幻想 英雄振臂一呼应者云集 战地黄
  • JavaScript BOM

    JavaScript BOM BOM Browser Object Model 浏览器对象模型 将浏览器的各个组成部分封装成不同的对象 方便我们进行操作 Windows窗口对象 定时器 唯一标识 setTimeout 功能 毫秒值 设置一次
  • 【java基础】Java如何卸载

    Java如何卸载 首先右键我的电脑 属性选择高级系统设置 找到环境变量 打开之后在系统变量里找到JAVA HOME 点击JAVA HOME复制变量值中的路径 在资源管理器中找到这个目录 将目录删除 再次打开环境变量 将JAVA HOME以及
  • 换肤实现及LayoutInflater原理

    文章目录 背景 实现换肤步骤 解析插件 apk 的包信息 获取插件 apk 的 Resources 对象 替换资源 简单的插件化换肤实现和存在的问题 换肤如何动态刷新 控件换肤刷新的性能考虑 如何降低 xml 布局中 View 的替换成本