写点什么

核心原理解析:开启 B 站少女心模式,探究 APP 换肤机制的设计与实现,androidstudio 菜鸟教程

作者:嘟嘟侠客
  • 2021 年 11 月 28 日
  • 本文字数:5042 字

    阅读完需:约 17 分钟

<TextView


android:layout_width="wrap_content"


android:layout_height="wrap_content"


android:text="Hello World"


android:textColor="@color/skinPrimaryTextColor" />


二、构建产品化思维:皮肤包


=========================================================================


如何衡量一个开发人员的能力——对复杂功能快速、稳定的交付?


如果只是单纯的认可这个理念,那么对于换肤功能的实现反而简单了,以标题颜色 skinPrimaryTextColor 为例,我只需要声明两个 color 资源:


<?xml version="1.0" encoding="utf-8"?>


<resources>


<color name="skinPrimaryTextColor">#000000</color>


<color name="skinPrimaryTextColor_Dark">#FFFFFF</color>


</resources>


笔者成功摆脱了复杂的编码实现,在 Activity 中我只需 2 行代码即可:


public void initView() {


if (isLightMode) { // 日间模式


tv.setTextColor(R.color.skinPrimaryTextColor);


} else { // 夜间模式


tv.setTextColor(R.color.skinPrimaryTextColor_Dark);


}


}


这种实现并非一无是处,从实现的难度而言,至少能够保护开发者为数不多的发囊。


当然,这种方案有「优化空间」,比如提供封装的工具方法 看似摆脱 无尽的 if-else:


/**


  • 获取当前皮肤下真正的 color 资源,所有 color 的获取都必须通过该方法。


**/


@ColorRes


public static int getColorRes(@ColorRes int colorRes) {


// 伪代码


if (isLightMode) { // 日间模式


return colorRes; // skinPrimaryTextColor


} else { // 夜间模式


return colorRes + "_Dark"; // skinPrimaryTextColor_Dark


}


}


// 代码中使用该方法,设置标题和次级标题颜色


tv.setTextColor(SkinUtil.getColorRes(R.color.skinPrimaryTextColor));


tvSubTitle.setTextColor(SkinUtil.getColorRes(R.color.skinSecondaryTextColor));


很明显,return colorRes + "_Dark"这行代码作为 int 类型的返回值是不成立的,读者无需关注具体实现,因为这种封装仍 未摆脱笨重的 if-else 实现 的本质。


可以预见,随着主题数量逐步增多,换肤相关的代码越来越臃肿,最关键的问题是,所有控件的相关颜色都强耦合于换肤相关代码本身,每个 UI 容器(Activity/Fragment/自定义 View)等需要追加 Java 代码手动设置。


此外,当皮肤数量达到一定规模时,color 资源的庞大势必影响到 apk 体积,因此主题资源的动态加载发势在必行,用户安装应用时默认只有一个主题,其它主题 按需下载和安装 ,比如淘宝:



到了这里,皮肤包的概念应运而出,开发者需要将单个主题的颜色资源视为一个皮肤包,在不同的主题下,对不同的皮肤包进行加载和资源替换:


<resources>


<color name="skinPrimaryTextColor">#000000</color>


...


</resources>


<resources>


<color name="skinPrimaryTextColor">#FFFFFF</color>


...


</resources>


这样,对于业务代码而言,开发者不再需要关注具体是哪个主题,只需要按常规的方式进行颜色的指定,系统会根据当前的颜色资源对 View 进行填充:


<TextView


android:layout_width="wrap_content"


android:layout_height="wrap_content"


android:text="Hello World"


android:textColor="@color/skinPrimaryTextColor" />


回到本小节最初的问题,产品化思维也是一个优秀的开发者不可或缺的能力:先根据需求罗列不同的实现方案,做出对应的权衡,最后动手编码。


三、整合思路


==================================================================


目前为止,一切都还停留在需求提出和设计阶段,随着需求的明确,技术难点逐一罗列在开发者面前。


1.动态刷新机制


开发者面临的第一个问题:如何实现换肤后的动态刷新功能。


以微信注册页面为例,手动切换到深色模式后,微信进行了页面的刷新:



读者不禁会问,动态刷新的意义是什么 ,让当前页面重建或者 APP 重启不行吗?


当然可行,但是 不合理 ,因为页面重建意味着页面状态的丢失,用户无法接受一个表单页面已填信息被重置;而如果要弥补这个问题,对每个页面重建追加状态的保存(Activity.onSaveInstanceState()),在实现的角度来看,也是一个巨大的工程量。


因此动态刷新势在必行——用户无论是在应用内切换了皮肤包,还是手动切换了系统的深色模式,我们如何将这个通知进行下发,保证所有页面都完成对应的刷新呢?


2.保存所有页面的 Activity


读者知道,我们可以通过 Application.registerActivityLifecycleCallbacks()方法观察到应用内所有 Activity 的生命周期,这也意味着我们可以持有所有的 Activity:


public class MyApp extends Application {


// 当前应用内的所有 Activity


private List<Activity> mPages = new ArrayList();


@Override


public void onCreate() {


super.onCreate();


registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {


@Override


public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {


mPages.add(activity);


}


@Override


public void onActivityDestroyed(@NonNull Activity activity) {


mPages.remove(activity);


}


// ...省略其它生命周期


});


}


}


有了所有的 Activity 的引用,开发者就可以在接到换肤通知的时候,第一时间尝试让所有页面的所有 View 去更新换肤。


3.成本问题


但巨大的谜团随之映入眼帘,对于控件而言,更新换肤这个概念本身并不存在。


什么意思呢?当换肤通知到达时,我无法令 TextView 更新文字颜色,也无法令 View 更新背景颜色——它们都只是系统的控件,执行的都是最基础的逻辑,说白了,开发者根本无法进行编码。


有同学说,那我直接让整个页面的整个 View 树所有 View 都全部重新渲染可以吗?可以,但是又回到了最初的问题,那就是所有 View 本身的状态也被重置了(比如 EditText 的文字被清零),退一步讲,即使这一点可以被接受,那么整个 View 树的重新渲染也会极大影响性能。


那么,如何尽可能的节省页面动态刷新的成本 ?


开发者希望,换肤发生时,只对指定控件的指定属性进行动态更新,比如,TextView 只关注更新 background 和 textColor,ViewGroup 只关注 background,其他的属性不需要重置和修改,将设备的每一分性能都利用到极致:


public interface SkinSupportable {


void updateSkin();


}


class SkinCompatTextView extends TextView implements SkinSupportable {


public void updateSkin() {


// 使用当前最新的资源更新 background 和 textColor


}


}


class SkinCompatFrameLayout extends FrameLayout implements SkinSupportable {


public void updateSkin() {


// 使用当前最新的资源更新 background


}


}


如代码所示,SkinSupportable 是一个接口,实现该接口的类意味着都支持动态刷新,当换肤发生时,我们只需要拿到当前的 Activity,并通过遍历 View 树,让所有 SkinSupportable 的实现类都去执行 updateSkin 方法进行自身的刷新,那么整个页面也就完成了换肤的刷新,同时不会影响 View 本身当前其他的属性。


当然,这也意味着开发者需要将常规的控件进行一轮覆盖性的封装,并提供出对应的依赖:


implementation 'skin.support:skin-support:1.0.0' // 基础控件支持,比如 SkinCompatTextView、SkinCompatFrameLayout 等


implementation 'skin.support:skin-support-cardview:1.0.0' // 三方控件支持,比如 SkinCompatCardView


implementation 'skin.support:skin-support-constraint-layout:1.0.0' // 三方控件支持,比如 SkinCompatConstraintLayout


从长期来看,针对控件一一封装,提供可组合选择的依赖,对于换肤库的设计者而言,库本身的开发成本其实并不高。


4.牵一发而动全身


但负责业务开发的开发者叫苦不迭。


按照目前的设计,岂不是工程的 xml 文件中所有控件都需要重新进行替换?


<TextView


android:layout_width="wrap_content"


android:layout_height="wrap_content"


android:text="Hello World"


android:textColor="@color/skinPrimaryTextColor" />


<skin.support.SkinCompatTextView


and


《Android 学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》

【docs.qq.com/doc/DSkNLaERkbnFoS0ZF】 完整内容开源分享


roid:layout_width="wrap_content"


android:layout_height="wrap_content"


android:text="Hello World"


android:textColor="@color/skinPrimaryTextColor" />


从另一个角度来看,这又是额外的成本,如果哪一天想要剔除或者替换换肤库,那么无异于一次新的重构。


因此设计者需要尽量避免类似 牵一发而动全身 的设计,最好是让开发者无感知的感受到换肤库的 动态更新。


5.着手点: LayoutInflater.Factory2


对 LayoutInflater 不了解的读者,可以参考 这篇文章 。



https://github.com/qingmei2/blogs/issues/25


了解 LayoutInflater 的读者应该知道,在解析 xml 文件并实例化 View 的过程中,LayoutInflater 通过自身的 Factory2 接口,将基础控件拦截并创建成对应的 AppCompatXXXView,既避免了反射创建 View 对性能的影响,也保证了向下的兼容性:


switch (name) {


// 解析 xml,基础组件都通过 new 方式进行创建


case "TextView":


view = new AppCompatTextView(context, attrs);


break;


case "ImageView":


view = new AppCompatImageView(context, attrs);


break;


case "Button":


view = new AppCompatButton(context, attrs);


break;


case "EditText":


view = new AppCompatEditText(context, attrs);


break;


// ...


default:


// 其他通过反射创建


}


一图以蔽之:



因此,LayoutInflater 本身的实现思路为我们提供了一个非常好的着手点,我们只需要对这段逻辑进行拦截,将控件的实例化委托给换肤库即可:



如图所示,我们使用 SkinCompatViewInflater 拦截替换了系统 LayoutInflater 本身的逻辑,以 CardView 为例,解析标签时,将 CardView 生成的逻辑委托给下面的依赖库,如果工程中添加了对应的依赖,那么就能生成对应的 SkinCompatCardView,其自然支持了动态换肤功能。


当然,这一切逻辑的实现,起源于工程添加对应的依赖,然后在 APP 启动时进行初始化:


implementation 'skin.support:skin-support:1.0.0'


implementation 'skin.support:skin-support-cardview:1.0.0'


// implementation 'skin.support:skin-support-constraint-layout:1.0.0' // 未添加 ConstraintLayout 换肤支持


// App.onCreate()


SkinCompatManager.withApplication(this)


.addInflater(new SkinAppCompatViewInflater()) // 基础控件换肤


.addInflater(new SkinCardViewInflater()) // cardView


//.addInflater(new SkinConstraintViewInflater()) // 未添加 ConstraintLayout 换肤支持


.init();


以 ConstraintLayout 为例,当没有对应的依赖时(),则会默认通过反射进行构造,生成标签本身对应的 ConstraintLayout,其本身因为未实现 SkinSupportable,自然不会进行换肤更新。


这样,库的设计者为换肤库提供了足够的灵活性,既避免了对现有工程大刀阔斧的修改,又保证极低的使用和迁移成本,如果我希望 移除 或者 替换 换肤库,只需要删除 build.gradle 中的依赖和 Application 中初始化的代码就可以了。


四、深入性探讨


===================================================================


接下来将针对换肤库本身更多细节进行深入性的探讨。


1、皮肤包加载策略


策略模式在换肤库的设计过程中也有非常良好的体现。


对于不同的皮肤包而言,其加载、安装的策略理应是不同的 ,举例来说:


  1. 每个 APP 都有一个默认的皮肤包(通常是日间模式),策略需要安装后立即对其进行加载;

  2. 如果皮肤包是远程的,用户点击切换皮肤,需要从远程拉取,下载成功后进行安装加载;

  3. 皮肤包下载安装成功,之后应该从本地 SD 卡进行加载;

  4. 其他自定义加载策略,比如远程的皮肤包有加密,本地加载后解密等。


因此,设计者应将皮肤包的加载和安装抽象为一个 SkinLoaderStrategy 接口,便于开发者更方便和灵活性的按需配置。


此外,由于加载行为本身极大可能是耗时操作,因此应该控制好线程的调度,并及时通过定义 SkinLoaderListener 回调,对加载的进度和结果进行及时的通知:

最后

在这里我和身边一些朋友特意整理了一份快速进阶为 Android 高级工程师的系统且全面的学习资料。涵盖了 Android 初级——Android 高级架构师进阶必备的一些学习技能。


附上:我们之前因为秋招收集的二十套一二线互联网公司 Android 面试真题(含 BAT、小米、华为、美团、滴滴)和我自己整理 Android 复习笔记(包含 Android 基础知识点、Android 扩展知识点、Android 源码解析、设计模式汇总、Gradle 知识点、常见算法题汇总。)



本文已被CODING开源项目:《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》收录

用户头像

嘟嘟侠客

关注

还未添加个人签名 2021.03.19 加入

还未添加个人简介

评论

发布
暂无评论
核心原理解析:开启B站少女心模式,探究APP换肤机制的设计与实现,androidstudio菜鸟教程