方案一:使用主题文件

定义换肤资源

在values/下新建一个xml文件,比如theme_attrs.xml,然后定义换肤的资源类型

<?xml version="1.0" encoding="utf-8"?>
<resources><attr name="theme_main_color" format="color|reference" /><attr name="theme_sub_color" format="color|reference" /><attr name="theme_main_bg" format="reference" />
</resources>

在主题中定义具体的资源

修改项目中已有的主题文件,定义我们的皮肤资源,因为主题是可以继承的,所以我们直接继承,然后修改我们需要自定义的即可,如下面的Theme.Style1

<resources xmlns:tools="http://schemas.android.com/tools"><style name="Theme.Default" parent="Theme.MaterialComponents.DayNight.DarkActionBar"><item name="theme_main_color">@color/purple_500</item><item name="theme_sub_color">@color/purple_200</item><item name="theme_main_bg">@mipmap/bg1</item></style><style name="Theme.Style1" parent="Theme.Default"><item name="theme_main_color">@color/purple_500</item><item name="theme_sub_color">@color/teal_200</item><item name="theme_main_bg">@mipmap/bg2</item></style>
</resources>

布局中使用

在布局中使用?attr/xxx的形式去引用主题中的实际资源

<TextViewandroid:layout_width="match_parent"android:layout_height="50dp"android:background="?attr/theme_main_color"android:gravity="center"android:text="这是一个文本"android:textColor="?attr/theme_sub_color" />

换肤

我们需要在setContentView之前设置我们的主题即可

override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)if ("default" != getSp(this, "theme")) {setTheme(R.style.Theme_Style1)}setContentView(R.layout.activity_demo1_theme)
}

这个时候就会存在一个问题,当我们换肤设置了setTheme以后,需要重新创建Activity才会生效

1、使用recreate()方法重新创建Activity

fun onChangeTheme(view: View) {toggleTheme()recreate()
}

缺点:画面会闪烁一下,体验上无法接受

2、无闪烁重启Activity

fun onChangeTheme2(view: View) {toggleTheme()val intent = intentintent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)finish()overridePendingTransition(0, 0) //不设置进入退出动画startActivity(intent)overridePendingTransition(0, 0) //不设置进入退出动画
}

以上两种方式都会导致界面状态丢失,避免方案咱们后面再讲

动态添加控件支持

很多时候,我们的控件并不是直接在xml中的,需要在运行时,使用addView添加到试图中,这个时候就需要我们手动应用主题属性了

fun onAddView(view: View) {val textView = TextView(this)textView.text = "动态添加的控件"textView.setTextColor(getThemeColor(this,R.attr.theme_sub_color,Color.BLACK))
}

1、方案一:使用obtainStyledAttributes获取属性值

/*** 获取主题属性的资源id*/
fun getThemeColor(context: Context, attr: Int, defaultColor: Int): Int {val obtainStyledAttributes = context.theme.obtainStyledAttributes(intArrayOf(attr))val redIds = IntArray(obtainStyledAttributes.indexCount)for (i in 0 until obtainStyledAttributes.indexCount) {val type = obtainStyledAttributes.getType(i)redIds[i] =//if (type >= TypedValue.TYPE_FIRST_COLOR_INT && type <= TypedValue.TYPE_LAST_COLOR_INT) {obtainStyledAttributes.getColor(i, defaultColor)} else {defaultColor}}obtainStyledAttributes.recycle()return redIds[0]
}

2、方案二:使用TypedValue获取

/*** 获取主题属性的资源id,方案二*/
fun getThemeColor2(context: Context, attr: Int, defaultColor: Int): Int {val typedValue = TypedValue()val success = context.theme.resolveAttribute(attr,typedValue,true)return if (success) {if (typedValue.type >= TypedValue.TYPE_FIRST_COLOR_INT&& typedValue.type <= TypedValue.TYPE_LAST_COLOR_INT) {typedValue.data} else {defaultColor}} else {defaultColor}
}
  • TypedValue字段解析

    针对#ffffff 这种指定值,data就为这个色值,resourceId为0

    针对@color/black,data为这个色值,resourceId为 R.color.black(整形)

    针对@drawable/XXX,data不能直接用,resourceId为 R.drawable.XXX(整形),type为TypedValue.TYPE_STRING,string字段为文件名

使用主题文件方案缺点:

1、当界面重启以后,界面状态会丢失

2、整体改造为attr形式,较为繁琐

方案二:LayoutInflater#setFactory2

既然使用setTheme方案都需要重新创建Activity,那么其实我们也可以自己找到所有需要换肤的控件,然后手动设置就可以完成换肤了,这种方案代表框架Android-Skin-Loader,不过可惜很久没有更新了

大致步骤如下:

1、收集需要换肤的控件以及属性

2、制作皮肤包

3、读取皮肤包

4、动态刷新控件

5、其他:支持手动设置属性,手动添加控件

其实我们查看LayoutInflater#createViewFromTag源码即可知道,系统在创建View之前会使用LayoutInflater#tryCreateView去看看外部是不是想自己创建控件,具体会调用外部设置的Factory2#onCreateView,如果返回null,则系统去创建,那么我们就可以在这个里面解析对应控件的属性,如果是支持换肤的属性,则创建自己手动控件,并保存

收集需要换肤的控件以及属性

首先将我们的LayoutInflater.Factory2设置进去,这里使用LayoutInflaterCompat来保证兼容性

override fun onCreate(savedInstanceState: Bundle?) {LayoutInflaterCompat.setFactory2(layoutInflater, layoutFactory2)super.onCreate(savedInstanceState)setContentView(R.layout.activity_demo2_theme)
}

然后就是在LayoutInflater.Factory2执行我们的逻辑,首先判断控件是否设置了允许换肤的属性(其实非必须,只是为了提升效率),然后读取属性名,如果我们支持,则继续读取属性的值,这里需要兼容直接写色值、使用?attr/xxx形式,以及@color/xxx,然后将其包装

private val layoutFactory2 = object : LayoutInflater.Factory2 {val attrViews: MutableList<AttrView> = mutableListOf()override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {val obtainStyledAttributes = context.obtainStyledAttributes(attrs, R.styleable.SkinSupport)val isEnable = obtainStyledAttributes.getBoolean(R.styleable.SkinSupport_enableSkin, false)obtainStyledAttributes.recycle()var createView: View? = null//如果控件支持换肤if (isEnable) {//调用系统方法创建控件createView = delegate.createView(parent, name, context, attrs)val attrView = AttrView(createView)for (i in 0 until attrs.attributeCount) {val attributeName = attrs.getAttributeName(i)//如果是支持换肤的属性if (isSupportAttr(attributeName)) {val attributeValue = attrs.getAttributeValue(i)//# 直接写死的颜色 不处理//?2130903258 ?colorPrimary 这样的 解析主题,找到id,再去找资源名称和类型//@2131231208 @color/red 直接就是id,根据id找到资源名称和类型if (attributeValue.startsWith("?")) {val attrId = attributeValue.substring(1)val resIdFromTheme = getResIdFromTheme(context, attrId.toInt())if (resIdFromTheme > 0) {attrView.attrs.add(AttrItem(attributeName, resIdFromTheme))}} else if (attributeValue.startsWith("@")) {attrView.attrs.add(AttrItem(attributeName, attributeValue.substring(1).toInt()))}}}attrViews.add(attrView)}return createView}/*** 解析主题,找到资源id,其实就是方案一里面的方法*/private fun getResIdFromTheme(context: Context, attrId: Int): Int {val typedValue = TypedValue()val success = context.theme.resolveAttribute(attrId, typedValue, true)//typedValue.resourceId 可能为0return typedValue.resourceId}private fun isSupportAttr(attrName: String): Boolean {if ("textColor" == attrName || "text" == attrName) {return true}return false}
}

这里其实主要关注点如下

1、如何创建View

使用delegate.createView(parent, name, context, attrs),委托给系统的实现,保证兼容性

2、如何读取?attr/xxx形式

如同方案一中,使用TypedValue读取即可

补充封装类

//包装一个属性
private class AttrItem(val attrName: String, val resId: Int)
//包装一个支持换肤的控件
private class AttrView(val view: View, val attrs: MutableList<AttrItem> = mutableListOf())

制作皮肤包

制作皮肤包也很简单,只需要新建一个Phone类型的Module即可,然后执行assembleRelease命令即可,这里需要注意的是,因为皮肤包只需要资源文件,所以各种代码依赖都需要删除掉,打包以后观察下app包的大小以及里面的dex文件内容即可。哪里多余删除哪里

读取皮肤包

读取外部apk资源网上代码已经非常多了,就不在多说了,主要是将外部的apk的路径添加到AssetManager,然后创建Resources对象,当我们换肤的时候,就是在这个Resources对象中寻找资源文件并替换

fun loadResource(context: Context, skinPath: String) {try {val packageArchiveInfo = context.packageManager.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES)if (packageArchiveInfo == null) {Log.w(TAG, "loadResource: app load fail")return}skinPkgName = packageArchiveInfo.packageNameval assetManager = AssetManager::class.java.newInstance()val method = AssetManager::class.java.getMethod("addAssetPath", String::class.java)method.invoke(assetManager, skinPath)resource = Resources(assetManager, context.resources.displayMetrics, context.resources.configuration)} catch (e: Exception) {Log.e(TAG, "loadResource: ", e)}
}

动态刷新控件

要想刷新控件也非常的简单,因为我们在第一步中已经找到了所有需要换肤的控件,以及每一个控件的属性、属性id,当换肤的时候,直接遍历这个列表,然后去皮肤包中寻找同名的资源设置给控件即可完成换肤

fun changeSkin(context: Context) {//这个是在Factory2中找到的所有支持换肤的控件attrViews.forEach {changAttrView(context, it)}
}fun changAttrView(context: Context, attrView: AttrView) {//将每一个换肤控件的属性进行应用attrView.attrs.forEach {if (attrView.view is TextView) {if (it.attrName == "textColor") {//去皮肤包中寻找对应的资源attrView.view.setTextColor(SkinLoader.instance.getTextColor(context, it.resId))} else if (it.attrName == "text") {//去皮肤包中寻找对应的资源attrView.view.text = SkinLoader.instance.getText(context, it.resId)}}}
}

获取插件工程的资源只需要三步

1、通过主工程的资源id获取资源名字,类型

2、通过资源名字、类型去插件包中寻找对应的资源id

3、通过插件资源id,用插件Resources对象去读取插件资源

fun getText(context: Context, redId: Int): String {//找到插件工程的对应资源idval identifier = getIdentifier(context, redId)if (resource == null || identifier <= 0) {return context.getString(redId)}//获取插件工程的资源return resource!!.getString(identifier)
}private fun getIdentifier(context: Context, redId: Int): Int {//主工程资源id->资源名字、类型->插件包中的资源id//R.color.black//blackval resourceEntryName = context.resources.getResourceEntryName(redId)//colorval resourceTypeName = context.resources.getResourceTypeName(redId)return resource?.getIdentifier(resourceEntryName, resourceTypeName, skinPkgName) ?: 0
}

Resources一些方法说明

//activity_main
Log.i(TAG, "${resources.getResourceEntryName(R.layout.activity_main)} ")
//org.learn.skinchangedemp:layout/activity_main
Log.i(TAG, "${resources.getResourceName(R.layout.activity_main)} ")
//org.learn.skinchangedemp
Log.i(TAG, "${resources.getResourcePackageName(R.layout.activity_main)} ")
//layout
Log.i(TAG, "${resources.getResourceTypeName(R.layout.activity_main)} ")
//资源id,packageName=插件包的包名
Log.i(TAG, "${resources.getIdentifier("activity_main", "layout", packageName)}")

支持手动设置属性,手动添加控件

同样的,换肤不仅仅需要支持xml中配置,也需要能动态添加,设置属性,通过上面的步骤其实也很简单,直接将控件封装成AttrView对象,属性封装成AttrItem即可。

fun onAddView(view: View) {val textView = TextView(this)val addAttr = layoutFactory2.dynamicAddSkin(textView).addAttr("text", R.string.test_string).addAttr("textColor", R.color.skin_test_color)mLL.addView(textView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))layoutFactory2.changAttrView(this, addAttr)
}//将控件封装成AttrView对象,然后添加到换肤的列表中
fun dynamicAddSkin(v: View): AttrView {val attrView = AttrView(v)attrViews.add(attrView)return attrView
}fun changAttrView(context: Context, attrView: AttrView) {//将每一个换肤控件的属性进行应用attrView.attrs.forEach {if (attrView.view is TextView) {if (it.attrName == "textColor") {//去皮肤包中寻找对应的资源attrView.view.setTextColor(SkinLoader.instance.getTextColor(context, it.resId))} else if (it.attrName == "text") {//去皮肤包中寻找对应的资源attrView.view.text = SkinLoader.instance.getText(context, it.resId)}}}
}

方案特点:

1、自动化程度比较高,改造成本也低

2、存在一定侵入性

方案三:使用tag标记需要换肤的属性

此方案其实与方案二的步骤非常相似,唯一不同的地方在于,方案二使用了layoutFactory去获取所有支持换肤的控件,本方案则是在控件上设置tag的方式来标记,方案二在创建布局的时候收集所有控件,性能上存在部分损耗,使用tag则是在换肤的时候,遍历控件树去修改属性。代表方案为AndroidChangeSkin

在xml中使用tag

<Buttonandroid:layout_width="match_parent"android:layout_height="50dp"android:layout_marginTop="10dp"android:background="#ffffff"android:gravity="center"android:tag="text=string/test_string|textColor=color/skin_test_color"android:text="@string/test_string"android:textColor="?attr/module_color" />

换肤的时候遍历视图树

private fun look(view: View) {if (view is ViewGroup) {for (i in 0 until view.childCount) {look(view.getChildAt(i))}}var tag = view.tagif (tag == null) {tag = view.getTag(R.id.skin_tag)}if (tag == null || tag !is String) {return}val attrView2 = AttrView2(view)val attrItem = tag.split("|")attrItem.forEach {val attrInfo = it.split("=")val kvAttr = attrInfo[1].split("/")attrView2.attrs.add(AttrItem2(attrInfo[0], kvAttr[1], kvAttr[0]))}mChangSkinViews.add(attrView2)
}

然后就是与方案二中一样,读取皮肤包资源咯

fun refreshUI() {mChangSkinViews.clear()look(findViewById(R.id.root_view))mChangSkinViews.forEach {if (it.view is TextView) {it.attrs.forEach { attr ->if (attr.attr == "textColor") {//去皮肤包中寻找对应的资源it.view.setTextColor(SkinLoader.instance.getTextColor(this, attr.attrName, attr.attrType))} else if (attr.attr == "text") {//去皮肤包中寻找对应的资源it.view.text = SkinLoader.instance.getText(this, attr.attrName, attr.attrType)}}}}
}

动态添加控件与方案二也差不多

fun onAddView(view: View) {val textView = TextView(this)textView.setText(getString(R.string.test_string))textView.setTextColor(resources.getColor(R.color.skin_test_color))textView.setTag(R.id.skin_tag, "text=string/test_string|textColor=color/skin_test_color")mLL.addView(textView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))textView.setOnClickListener {mLL.removeView(it)}refreshUI()
}

方案特点:侵入性较低,但是使用、改造成本比较高

本文Demo地址:https://github.com/CB2Git/SkinChangeDemo

参考博文:

https://blog.csdn.net/awangyunke/article/details/121234998

https://www.jianshu.com/p/3b55e84742e5

Android三种换肤方案原理及Demo相关推荐

  1. Android 主题切换/换肤方案 研究(四) - qq和qq空间

    4. qq和qq空间 (独立app) 分析时用的是: 1. 夜神android模拟器(因为用android studio自带的模拟器运行x86架构的镜像提示不能安装qq空间,安装arm架构的镜像运行又 ...

  2. Android 几种换肤方式和原理分析

    1.通过Theme切换主题 通过在setContentView之前设置Theme实现主题切换. 在styles.xml定义一个夜间主题和白天主题: <style name="Light ...

  3. Android Android-skin-support 换肤方案 原理讲解

    文章目录 前言 思考一下 开源库中 找到答案 结束语 前言 请先查看这两篇文章 LayoutInflater.Factory Android xml解析到View的过程 Android 无需自定义Vi ...

  4. Android APK方式换肤实现原理

    现在很多APP都有换肤的功能,例如微博,QQ等应用.这些应用的换肤原理是什么? 在用微博的时候,不难发现,当你要换肤时,先下载并安装一个皮肤apk,然后选择这个皮肤,就可以了. 这种方式就是把皮肤打包 ...

  5. Android 应用换肤方案的总结

    虽然现在已经有很多不错的换肤方案,但是这些方案或多或少都存在自己的问题.在这篇文章中,我将对 Android 现有的一些动态换肤方案进行梳理,对其底层实现原理进行分析,然后对开发一个新的换肤方案的可能 ...

  6. 对 Android 应用换肤方案的总结

    作者:me 虽然现在已经有很多不错的换肤方案,但是这些方案或多或少都存在自己的问题.在这篇文章中,我将对 Android 现有的一些动态换肤方案进行梳理,对其底层实现原理进行分析,然后对开发一个新的换 ...

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

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

  8. Android App节日换肤

    Android App节日换肤 Android App节日换肤 1原理 2使用方式 1在XML中给需要换肤的控件添加tag属性 2在Activity中使用 3还有疑问吧 3示例图 比如支付宝,饿了么, ...

  9. 最成熟的前端换肤方案(主题切换)

    前言 在网上找了很多的换肤方案,其中我认为写的最好的也是有demo 的无疑是这篇,但是同时也发现了一些问题,就是太多方案不知道选哪个,而且没有做持久化处理,并且没有把图片切换的代码放在一起.我这边的需 ...

最新文章

  1. 为 ASP.NET Datagrid 创建自定义列
  2. 添加service到SystemService硬件服务
  3. php curl异步跳转,php curl批处理--可控并发异步
  4. echarts 怎么知道鼠标点击的哪根柱子
  5. 腾讯云云机安装dockers
  6. WDA演练一:用户登陆界面设计(一)
  7. LeetCode 438. 找到字符串中所有字母异位词(双指针+滑动窗口)
  8. Python 3.4中文编码
  9. python风险评分卡系统_《智能风控:Python金融风险管理与评分卡建模》(梅子行,毛鑫宇)【摘要 书评 试读】- 京东图书...
  10. 《重构》阅读笔记-代码的坏味道
  11. 阶段3 2.Spring_06.Spring的新注解_7 spring整合junit问题分析
  12. VS2019离线安装方法
  13. 什么是大数据,模式识别和人工智能算法实现
  14. Win系统 - 如何解决 Windows + P 键无法切换双显复制模式?
  15. 动态规划 之 完全背包
  16. eslint 修改standard规则
  17. 软件是如何驱动硬件的,代码是怎样对计算机实现控制的?
  18. [BZOJ5145] [Ynoi2018] 五彩斑斓的世界 [并查集][分块][摊还分析]
  19. 【GUI制作】tkinter-一款跨平台的简易GUI库
  20. 解决uniapp手机浏览器视频封面不显示问题

热门文章

  1. android 顶部导航栏 自定义,Android自定义NavigationController - 安卓自定义导航栏 --【WJ】...
  2. 微软Web Deploy在Windows Server 2003上的安装配置
  3. 浅谈LCA问题(最近公共祖先)(三种做法)
  4. Kali 用户名及密码找寻记
  5. 你可以不精通Vue,但一定要精通JS!
  6. 小迈网关对接平台——Jetlinks开源物联网平台
  7. java中获取绝对值的方法_Java完美判断绝对值的两种方法 | 彬菌
  8. 给在校大学生的一封信,助梦启航!
  9. 去除图片周围的像素填充
  10. LeetCode 289 Game of Life(生命游戏)(Array)