换肤实现及LayoutInflater原理

news/2024/4/25 18:01:23/文章来源:https://blog.csdn.net/qq_31339141/article/details/119975427

文章目录

  • 背景
  • 实现换肤步骤
    • 解析插件 apk 的包信息
    • 获取插件 apk 的 Resources 对象
    • 替换资源
  • 简单的插件化换肤实现和存在的问题
    • 换肤如何动态刷新?
    • 控件换肤刷新的性能考虑
    • 如何降低 xml 布局中 View 的替换成本
      • LayoutInflater 原理
      • LayoutInflater.Factory2 替换 View
  • 小结
  • 多线程 inflate 存在的隐患

背景

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

在 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"><ImageViewandroid:id="@+id/image_view"android:layout_width="100dp"android:layout_height="100dp"android:background="@drawable/test"tools:ignore="ContentDescription" /><Buttonandroid: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;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);ActivityMainBinding binding = ActivityMainBinding.inflate(getLayoutInflater());setContentView(binding.getRoot());binding.btnReplace.setOnClickListener(new View.OnClickListener() {@Overridepublic 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.testint 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.childprivate 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 的 Resourcesprivate 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 查找对应资源名称的 idprivate 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() {@Overridepublic void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {// getLayoutFactory2() 返回 LayoutInflater.Factory2 实现类// 记录所有需要换肤的 ViewLayoutInflater inflater = LayoutInflater.from(activity);inflater.setFactory2(getLayoutFactory2(activity));}@Overridepublic void onActivityResumed(Activity activity) {// 以 activity 为 key,获取对应界面的 LayoutInflater.Factory2// 界面可见时尝试通知 View 刷新,主要处理从其他位置操作换肤返回后及时刷新换肤效果SkinObserver observer = getObserver(activity);observer.updateSkinIfNeed();}@Overridepublic 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:

<!-- 替换前 -->
<TextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:text="Hello World"android:textColor="@color/skin_color" /><!-- 替换后 -->
<com.example.skin.widget.SkinTextViewandroid: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"><TextViewandroid:id="@+id/text_view"android:layout_width="wrap_content"android:layout_height="wrap_content" /><ImageViewandroid: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 {// 解析 xmlreturn 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 {// 创建 viewfinal View temp = createViewFromTag(root, name, inflaterContext, attrs);ViewGroup.LayoutParams params = null;if (root != null) {// 创建 root 的 LayoutParamsparams = root.generateLayoutParams(attrs);if (!attachToRoot) {// 布局创建的 view 使用 root 的 LayoutParamstemp.setLayoutParams(params);}}// 创建子 viewrInflateChildren(parser, temp, attrs, true);// 如果 root != null && attachToRoot=true// 布局的 view 会添加到指定的 rootif (root != null && attachToRoot) {root.addView(temp, params);}// 如果 root == null || attachToRoot=false// 布局的 view 就是顶层的 viewif (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,优先使用它创建 viewview = mFactory2.onCreateView(parent, name, context, attrs);} else if (mFactory != null) {// 如果 LayoutInflater.Factory != null,使用它创建 viewview = 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,通过反射创建 viewif (-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 {...@Overridepublic 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 替换为 AppCompatTextViewcase "TextView":view = new AppCompatTextView(context, attrs);break;...}} 
}

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

public class AppCompatActivity extends FragmentActivity implements AppCompatCallback,TaskStackBuilder.SupportParentable, ActionBarDrawerToggle.DelegateProvider {...@Overrideprotected void onCreate(@Nullable Bundle savedInstanceState) {final AppCompatDelegate delegate = getDelegate();delegate.installViewFactory(); // 设置 LayoutInflater.Factory2delegate.onCreate(savedInstanceState);super.onCreate(savedInstanceState);}
}class AppCompatDelegateImpl extends AppCompatDelegateimplements MenuBuilder.Callback, LayoutInflater.Factory2 {...@Overridepublic 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<>();@Overricepublic View onCreateView(View parent, String name, Context context, AttributeSet attrs) {// 自定义创建 ViewView view = createView(context, name, attrs);// 如果没有创建 View,返回 null 最后会通过反射创建 Viewif (view == null) {return null;}if (view instanceOf ISkinUpdater) {mSkinUpdaters.add(new WeakReference<>((ISkinUpdater) view)));}return view;}@Overridepublic View onCreateView(String name, Context context, AttributeSet attrs) {// 自定义创建 ViewView view = createView(context, name, attrs);// 如果没有创建 View,返回 null 最后会通过反射创建 Viewif (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 创建 viewif (view == null) {view = createViewFromInflater(context, name, attrs);}// 反射创建 viewif (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);	}@Overridepublic void onActivityCreated(Activity activity, Bundle savedInstanceState) {// activity 创建时设置 LayoutInflater.Factory2 创建 ViewinstallLayoutFactory(activity); }@Overridepublic void onActivityResumed(Activity activity) {// 界面返回刷新可见页面换肤}@Overridepublic 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 获取了错误的构造函数,导致反射创建时出现 ClassCastExceptionreturn 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 {@Overridepublic View onCreateView(View parent, String name, Context context, AttributeSet attrs) {// 添加类锁,确保线程安全问题synchronized(MyFactory2.class) {View view = createView(parent, name, context, attrs);...}}@Overridepublic 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<>();...
}

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.luyixian.cn/news_show_103805.aspx

如若内容造成侵权/违法违规/事实不符,请联系dt猫网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

David Silver Reinforcement Learning -- Markov process

1 Introduction 这个章节介绍关键的理论概念。 马尔科夫过程的作用&#xff1a; 1&#xff09;马尔科夫过程描述强化学习环境的方法&#xff0c;环境是完全能观测的&#xff1b; 2&#xff09;几乎所有的RL问题可以转换成MDP的形式&#xff1b; 2 Markov Processes 2.1 Mark…

从源码全面解析LinkedBlockingQueue的来龙去脉

&#x1f44f;作者简介&#xff1a;大家好&#xff0c;我是爱敲代码的小黄&#xff0c;独角兽企业的Java开发工程师&#xff0c;CSDN博客专家&#xff0c;阿里云专家博主&#x1f4d5;系列专栏&#xff1a;Java设计模式、数据结构和算法、Kafka从入门到成神、Kafka从成神到升仙…

mall-swarm微服务商城系统

mall-swarm是一套微服务商城系统&#xff0c;采用了 Spring Cloud 2021 & Alibaba、Spring Boot 2.7、Oauth2、MyBatis、Docker、Elasticsearch、Kubernetes等核心技术&#xff0c;同时提供了基于Vue的管理后台方便快速搭建系统。mall-swarm在电商业务的基础集成了注册中心…

【Excel统计分析插件】上海道宁为您提供统计分析、数据可视化和建模软件——Analyse-it

Analyse-it是Microsoft Excel中的 统计分析插件 它为Microsoft Excel带来了 易于使用的统计软件 Analyse-it在软件中 引入了一些新的创新统计分析 Analyse-it与 许多Excel加载项开发人员不同 使用完善的软件开发和QA实践 包括单元/集成/系统测试 敏捷开发、代码审查 …

HCIA-RS实验-ENSP搭建一个基础的IP网络

HCIA-RS是华为认证网络工程师&#xff08;Routing & Switching&#xff09;的缩写。通过考取HCIA-RS证书&#xff0c;可以证明自己有能力设计、实现和维护小型网络。而HCIA-RS实验则是考试的一部分&#xff0c;是考生必须要完成的实践环节。这将是第一篇文章&#xff0c;后…

【Android Framework (八) 】- Service

文章目录 知识回顾启动第一个流程initZygote的流程system_serverServiceManagerBinderLauncher的启动AMS 前言源码分析1.startService2.bindService 拓展知识1:Service的两种启动方式对Service生命周期有什么影响&#xff1f;2:Service的启动流程3:Service的onStartCommand返回…

紧密联结玩家 | 2023 Google 游戏开发者峰会

玩家的选择是对游戏莫大的认可&#xff0c;重视玩家反馈并和他们建立联系是您的游戏取得成功的关键。我们也在努力创造更多机会&#xff0c;让您的游戏从琳琅满目的列表中脱颖而出&#xff0c;帮助您吸引更多用户。 上篇内容我们介绍了帮助您优化游戏性能的几大功能更新&#x…

❀五一劳动节来啦❀

今年“五一”&#xff0c;4月29日至5月3日放假调休&#xff0c;共5天。 如果你在5月4日到5月6日请假3天&#xff0c;加上5月7日周日&#xff0c;就可以形成9天的假期。 一&#xff0c;五一劳动节的由来⭐ 国际劳动节又称“五一国际劳动节”“国际示威游行日”&#xff08;英语…

GPT详细安装教程-GPT软件国内也能使用

GPT (Generative Pre-trained Transformer) 是一种基于 Transformer 模型的自然语言处理模型&#xff0c;由 OpenAI 提出&#xff0c;可以应用于各种任务&#xff0c;如对话系统、文本生成、机器翻译等。GPT-3 是目前最大的语言模型之一&#xff0c;其预训练参数超过了 13 亿个…

python+vue 健康体检预约管理系统

该专门体检预约管理系统包括会员和管理员。其主要功能包括个人中心、会员管理、体检服务管理、类型管理、订单信息管理、取消订单管理、 体检报告管理、通知信息管理、交流论坛、系统管理等功能。 目 录 一、绪论 1 1.1研发背景和意义 2 1.2 国内研究动态 3 1.3论文主…

Cookies和Session案例-注册

1. 注册功能改进 1.1 service 将之前的注册案例的代码进行优化&#xff0c;将获取sqlsession工厂对象、获取sqlsession、获取mapper等操作从servlet中分离出来转变为三层架构的形式 在service目录下创建UserService public class UserService {SqlSessionFactory sqlSessionFa…

Docker compose-实现多服务、nginx负载均衡、--scale参数解决端口冲突问题

Docker compose-实现多服务、nginx负载均衡、--scale参数解决端口冲突问题 问题&#xff1a;scale参数端口冲突解决方法&#xff1a;nginx实现多服务、负载均衡修改docker-compose.yml配置新增nginx本地配置文件验证启动容器查看容器状态访问web应用 问题&#xff1a;scale参数…

Linux中的YUM源仓库和NFS文件共享服务(うたかたの夢)

YUM仓库源的介绍和相关信息 简介 yum是一个基于RPM包&#xff08;是Red-Hat Package Manager红帽软件包管理器的缩写&#xff09;构建的软件更新机制&#xff0c;能够自动解决软件包之间的依赖关系。 yum由仓库和客户端组成&#xff0c;也就是整个yum由两部分组成&#xff0…

Python小姿势 - 知识点:

知识点&#xff1a; Python的字符串格式化 标题&#xff1a; Python字符串格式化实例解析 顺便介绍一下我的另一篇专栏&#xff0c; 《100天精通Python - 快速入门到黑科技》专栏&#xff0c;是由 CSDN 内容合伙人丨全站排名 Top 4 的硬核博主 不吃西红柿 倾力打造。 基础知识…

Docker的实际应用

一、 数据持久化 我们什么情况下要做数据持久化呢&#xff1f; 一定是在做容器之前先预判好哪些文件是要永久存储的&#xff0c; 而不会跟着它容器的一个生命周期而消失。 比如说配置文件、 日志文件、 缓存文件或者应用数据等等。 数据初始化有三种类型。 第一种 volumes&…

什么是分库分表?为什么需要分表?什么时候分库分表

不急于上手实战 ShardingSphere 框架&#xff0c;先来复习下分库分表的基础概念&#xff0c;技术名词大多晦涩难懂&#xff0c;不要死记硬背理解最重要&#xff0c;当你捅破那层窗户纸&#xff0c;发现其实它也就那么回事。 什么是分库分表 分库分表是在海量数据下&#xff0…

SCI论文自由投稿Vs专栏投稿,哪个更好中?

我们首先来看下以下几种期刊的发表方式&#xff1a; 正刊 正刊也就是自由投稿方式的发表方式&#xff0c;是期刊正常出版的期刊&#xff0c;比如一本SCI期刊是双月刊&#xff0c;一年出版6期&#xff0c;没有设定主题&#xff0c;包含多个研究方向的文章。每年按照半月/月/双…

100种思维模型之指数对数思维模型-54

对数、指数&#xff0c;生活中的2种增长曲线&#xff1b;对数增长曲线&#xff0c;即在开始时增长很快&#xff0c;但随着时间的推移&#xff0c;收益会减少并变得更加困难&#xff1b;而指数增长曲线&#xff0c;即开始时增长缓慢&#xff0c;但随着时间的推移&#xff0c;收益…

word表格

1 样式入口 插入新的表格 “插入”选项卡 > “表格”光标放在表格内 > 出现“表格工具”选项卡“表设计”选项卡 > “表格样式”栏目 > 在随便一个样式上右键 > 弹出“右键菜单” 常用的是“新建/修改/删除表格样式““设为默认值”&#xff1a;将指定样式设为…

Android studio 使用入门

安装 安装JDK https://www.oracle.com/java/technologies/downloads/ 新增变量JAVA_HOME&#xff0c;值为JDK安装根目录 在path中增加 %JAVA_HOME%\bin;%JAVA_HOME%\jre\bin; 安装 Android studio https://developer.android.google.cn/studio/ 注意&#xff1a;路径尽量不要包…