/   今日科技快讯   /

近日,谷歌母公司Alphabet公布了截至6月30日的2021年第二季度财报。报告显示,Alphabet第二季度总营收为618.80亿美元,较去年同期的382.97亿美元增长62%;第二季度净利润为185.25亿美元,较去年同期的69.59亿美元增长166.2%。

/   作者简介   /

本篇文章转载自却把清梅嗅的博客,文章主要分享了他对APP换肤机制的研究、设计以及实现,相信会对大家有所帮助!

原文地址:

https://juejin.cn/post/6965010289561436174

/   概述   /

换肤功能并非奇技淫巧,而是已非常普及,尤其当Android Q推出了深色模式之后,国内绝大多数主流应用都至少提供了日间和夜间两种模式。

对于无感的用户而言,这个功能实属鸡肋,但从另外一个角度上来说,这也是产品在雕琢用户极致体验过程中的一次尝试,为不同情景下,不同偏好的用户提供更多的选择性。

以哔哩哔哩为例,除了提供以上两种主题之外,还免费提供了充满少女心的粉色主题:

从产品的前瞻性上来看,国内在换肤功能的探索较国外是领先的,抽象的来看待Android Q的深色模式,也无非是新增一种主题罢了,因此,开发者应该将角度放在更高的层级上:为产品提供一套完善的换肤方案,而非仅仅是适配深色模式 。

想清楚这一点,开发者就不会将目光仅局限于技术本身——对于整个换肤体系而言,涵盖了UI、产品、开发、测试、运维等多名角色不同的关注点,而这些关注点最终却都依赖研发协助做决策,举例如下:

  • UI:定义不同的UI组件不同的颜色属性,这些属性最终在不同的主题下,代表不同的颜色(日间模式下标题是黑色,但是夜间模式下,标题应该是白色)。

  • 产品:定义换肤功能的业务流程,从简单的换肤主页,换肤的交互,到不同主题下的不同展示、付费策略等等。

  • 开发:提供换肤功能的研发能力。

  • 测试:保证换肤功能的稳定性,比如自动化测试和便捷取色工具。

  • 运维:保证线上问题的快速定位和及时解决。

除此之外,还有更多可以深入思考的技术点,比如,随着主题越来越多,势必导致APK包体积的增大,是否有必要引入远程动态加载(download & install)的能力?借助不同角色的视角,我们可以提前规划好远景,接下来的编码也就更加遂心应手。

本文将针对Android 应用整个换肤体系进行概括性的描述,读者应抛开对代码实现的细节的执著,从不同角色的需求去思考,窥一斑而知全豹,为产品打造出健壮有力的技术支撑。

/   定义UI规范   /

换肤规范的目的是什么?对于UI设计和开发人员而言,设计与开发都应该基于统一且完整的规范之上进行,以掘金APP为例:

对于UI设计人员,在APP不同的主题下,控件的颜色不再是一个单一的值,而应该用一个通用的key来进行定义,如上图所示,标题的颜色,在日间应该是黑色#000000,而深色模式下则应该为白色#FFFFFF,同理,次级标题、主背景色、分割线颜色,都应该随着不同的主题下,对应不同的值。

设计人员在设计时,仅需要针对页面每一个元素填充好对应的key,根据规范很清晰地完成UI设计:

这对于开发人员的效率提升更加明显,开发者不再需要关心具体颜色的值,只需要将对应的color填充到布局中即可:

<TextViewandroid: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体积,因此主题资源的动态加载发势在必行,用户安装应用时默认只有一个主题,其它主题按需下载和安装 ,比如淘宝:

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

<!--日间模式皮肤包的colors.xml-->
<resources><color name="skinPrimaryTextColor">#000000</color>...
</resources><!--深色模式皮肤包的colors.xml-->
<resources><color name="skinPrimaryTextColor">#FFFFFF</color>...
</resources>

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

<!--当前切换到什么主题,系统就用对应的颜色值进行填充-->
<TextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:text="Hello World"android:textColor="@color/skinPrimaryTextColor" />

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

/   整合思路   /

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

动态刷新机制

开发者面临的第一个问题:如何实现换肤后的动态刷新功能。以微信注册页面为例,手动切换到深色模式后,微信进行了页面的刷新:

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

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

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

保存所有页面的Activity

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

public class MyApp extends Application {// 当前应用内的所有Activityprivate List<Activity> mPages = new ArrayList();@Overridepublic void onCreate() {super.onCreate();registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {@Overridepublic void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {mPages.add(activity);}@Overridepublic void onActivityDestroyed(@NonNull Activity activity) {mPages.remove(activity);}// ...省略其它生命周期});}
}

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

成本问题

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

什么意思呢?当换肤通知到达时,我无法令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

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

牵一发而动全身

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

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

<!--使用前-->
<TextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:text="Hello World"android:textColor="@color/skinPrimaryTextColor" /><!--需要替换为-->
<skin.support.SkinCompatTextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:text="Hello World"android:textColor="@color/skinPrimaryTextColor" />

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

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

着手点:LayoutInflater.Factory2

了解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. 每个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);
}   

进一步节省性能

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

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

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

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

提供图片资源的换肤能力

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

/   小结   /

小结并不是总结,还有更多内容可以扩展,比如:

  • Android系统中Resources类是如何实现资源的替换的,换肤库中又做了哪些处理?

  • LayoutInflater源码中明确表示,一个LayoutInflater只能设置一次setFactory2(),否则会抛出异常,那么,换肤库是在哪个时机进行Factory2的注入的呢,为什么要这样设计?

  • 如何根据需求进一步扩展换肤库的功能,比如提供单页面不换肤的支持,以及提供多个页面使用不同皮肤包的支持?

  • 如何提供更多测试阶段、运维阶段可以利用的工具?

  • 截止笔者发稿时,2021 Google IO 大会上又提出了新的UI设计理念 Material You,将主题的概念从APP上升到了整个操作系统,其对于现有的换肤功能是否有新的影响?

实现没有终点,开发者能够做到的是通过不断多方位的反思,为产品提供展示更多价值的可能性,从而更进一步,完成自身专业能力阶段性的跨越。

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

使用OpenGL挑战抖音蓝线特效

PermissionX 1.5发布,支持申请Android特殊权限啦

欢迎关注我的公众号

学习技术或投稿

长按上图,识别图中二维码即可关注

探究APP换肤机制的设计与实现相关推荐

  1. 反思|开启B站少女心模式,探究APP换肤机制的设计与实现

    反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里 . 概述 换肤功能 并非奇技淫巧,而是已非常普及,尤其当Android Q推出了 深色模式 之后,国内绝大多数主流应用都至少提供 ...

  2. Android App 换肤实现方式

    Android App 换肤的引入意味着给用户提供不同的界面样式,以适应不同用户的审美需求.引入换肤可以让用户更加个性化地使用 App,增强用户对 App 的黏度和使用体验. Android App ...

  3. miui主题风格_一种android系统换肤功能的设计,董红光:MIUI主题风格.pdf

    MIUI主题风格主题风格主题风格主题风格 一种Android系统换肤功能的设计思路 董红光 2/29/2012 "主题"是什么 ? Symbian的"主题" A ...

  4. 多套主题怎么灵动换肤?APP换肤方案详细解析!

    背景需求 目前Android APP换肤大体可分为两大类: 两套主题的切换(比如白天/黑夜),使用一个开关按钮进行切换. 多套主题在线下载并更新. 第一种的实现基本上使用设置本地Theme来操作,即将 ...

  5. android app换肤详解

    前言 现在很多APP中都会有换肤功能,看着很神奇,一键点击app大换样,那么这篇就来简单阐释一下换肤是如何实现的 效果图 图1 图2 换肤实现方式? 软件换肤从功能上可以划分三种: 1) 软件内置多个 ...

  6. App邀请好友机制如何设计?且看这些方法

    在AARRR漏斗中如何定位 在研究产品生命周期的AARRR沙漏模型中,我们甚至可以将Referral(传播推荐)看作刺激活跃和提高留存的重要因素,因为很多时候这五个阶段并不一定是阶梯性转化的,几个环节 ...

  7. android app换肤Android-skin-support的简单使用

    1.添加依赖(具体的可以到框架地址查看) GitHub - ximsfei/Android-skin-support: Android-skin-support is an easy dynamic ...

  8. 换肤方案,换肤策略,App插件式换肤实现方案

    UI换皮肤或白天黑夜模式,从产品上来看,是两种不同产品设计模式:白天黑夜模式只有两种模式:而换皮肤可以有多套,可以进行商业化,并盈利. 换肤的本质就是去替换资源文件.我们知道,Android应用程序由 ...

  9. iOS拓展---【转载】iOS客户端节日换肤方案探究

    [转载]iOS客户端节日换肤方案探究 一.前言: Tip: 本来这篇文章在圣诞节就已经准备好了,但是由于种种原因一直没有写完,今天将它写出来,也算是2018年的第一篇文章了.你好,2018! 过去圣诞 ...

最新文章

  1. 167.两数之和Ⅱ-输入有序数组
  2. 伪静态php配置,PHP开启伪静态配置
  3. 属性被分为八大类不包括_Python语言---私有属性
  4. 收藏 | Python必备技能之 25个Matplotlib常用代码!
  5. aes算法c语言实现_C语言实现常用数据结构:Dijkstra最短路径算法(第18篇)
  6. c++ 优先队列_数据结构 | TencentOStiny中队列、环形队列、优先级队列的实现及使用...
  7. HDU4628+状态压缩DP
  8. 计算机集成项目经理 培训,计算机信息系统集成项目经理培训总结.doc
  9. C#敏感词过滤算法实现
  10. 国赛助力:第三类边界条件热传导方程及基于三对角矩阵的数值计算MATLAB实现(2020A)
  11. Python(基础)输出与输入
  12. linux配置dhcp服务器authoritative参数作用
  13. 音频处理——音频编码原理简介
  14. html5 main form 结合,web组件之表单(HTML5)
  15. Idea设置全白色 背景
  16. mysql常用的tamper脚本,tampermonkey 实用脚本
  17. 扁平化设计与质感设计: 他们有什么不同?
  18. 带参数传递的Verilog模块设计
  19. EFR32--如何在EFR32程序中修改UUID
  20. HttpGet和HttpPost的区别

热门文章

  1. 为什么要劝退分子科学与工程?
  2. *转载*耶鲁大学校长给计算机学生的建议
  3. win7 文件夹工具栏
  4. 项目管理计算:EV、PV、AC、BAC、CV、SV、EAC、ETC、CPI、SPI各是什么意思
  5. Git Github
  6. 思科交换机使用TFTP工具备份配置和上传配置
  7. 【无人机 学习笔记 1】无人机导航技术及其特点分析
  8. 人工智能中的认识导航技术
  9. 2020年超级计算机排名,2020中国高性能计算机TOP100榜单正式发布
  10. 关于ALEXA的流量排名的参数介绍