反思 _ 开启B站少女心模式,探究APP换肤机制的设计与实现,独家发布


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

三、整合思路
------

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

### 1.动态刷新机制

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

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

![hot_update_wechat.gif](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/baaccf5b15854f2794fe3e7edbbf082a~tplv-k3u1fbpfcp-watermark.image)

读者不禁会问,**动态刷新的意义是什么** ,让当前页面重建或者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文件`中所有控件都需要重新进行替换?

<skin.support.SkinCompatTextView
android:layout_width=“wrap_content”
android:layout_height=“wrap_content”
android:text=“Hello World”
android:textColor="@color/skinPrimaryTextColor" />


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

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

### 5.着手点: LayoutInflater.Factory2

> 对 `LayoutInflater` 不了解的读者,可以参考笔者的 [这篇文章](

) 。

了解`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:
// 其他通过反射创建
}


一图以蔽之:

![skin_layout_inflater.png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ed95cf319ab34c41b8d72e2bd45d315c~tplv-k3u1fbpfcp-watermark.image)

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

![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5c3b20bda1f74c9597c536699434584b~tplv-k3u1fbpfcp-zoom-1.image)

如图所示,我们使用`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`回调,对加载的进度和结果进行及时的通知:

```
/**
 * 皮肤包加载策略.
 */
public interface SkinLoaderStrategy {
    /**
     * 加载皮肤包.
     */
    String loadSkinInBackground(Context context, String skinName, SkinLoaderListener listener);
}

/**
 * 皮肤包加载监听.
 */
public interface SkinLoaderListener {
    /**
     * 开始加载.
     */
    void onStart();

    /**
     * 加载成功.
     */
    void onSuccess();

    /**
     * 加载失败.
     */
    void onFailed(String errMsg);
} 
```

### 2、进一步节省性能

上文中,笔者提到,因为持有了所有的`Activity`的引用,所以换肤库在换肤后,可以尝试让所有页面的所有`View`去更新换肤。

实际上「更新所有页面动」通常是没必要的,更合理的方式是提供一个可配置项,换肤成功时,默认只刷新前台的`Activity`,其它页面在`onResume`执行后再更新,这样能够大幅度降低渲染带来的性能影响。

此外,每次换肤重复的遍历`View`树进行刷新也是一个耗时的操作,可以通过在`LayoutInflater`创建`View`树的同时,将实现了`SkinSupportable`的`View`存在页面所属的一个集合中,当换肤发生时,只需要针对集合中的`View`进行更新即可。

最后,可以将上述文字中的`Activity`和`View`都通过弱引用去持有,以降低内存泄漏的可能。

### 3、提供图片资源的换肤能力

既然`color`资源能够支持换肤,`drawable`资源理所当然也应该提供支持,这样页面的展示可以更加多元化,通常这种场景应用于页面的背景图,对此读者可以参考淘宝APP的换肤功能效果:

| 资源Key | 日间模式 | 深色模式 | 备注 |
| --- | --- | --- | --- |
| skinPrimaryTextColor | #000000 | #FFFFFF | 标题字体颜色 |
| skinSecondaryTextColor | #CCCCCC | #CCCCCC | 次级标题字体颜色 |
| **skinMainBgDrawable** | A图片 | B图片 | 页面主背景图 |
| **skinProgressBarDrawable** | C动画 | D动画 | 加载框动画 |
| 其他更多... |  |  |  |



## 文末

> 面试:如果不准备充分的面试,完全是浪费时间,更是对自己的不负责!

不管怎么样,不论是什么样的大小面试,要想不被面试官虐的不要不要的,只有刷爆面试题题做好全面的准备,当然除了这个还需要在平时把自己的基础打扎实,这样不论面试官怎么样一个知识点里往死里凿,你也能应付如流啊**[CodeChina开源项目:《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》](

)**
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值