Android三种换肤方案原理及Demo
方案一:使用主题文件
定义换肤资源
在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相关推荐
- Android 主题切换/换肤方案 研究(四) - qq和qq空间
4. qq和qq空间 (独立app) 分析时用的是: 1. 夜神android模拟器(因为用android studio自带的模拟器运行x86架构的镜像提示不能安装qq空间,安装arm架构的镜像运行又 ...
- Android 几种换肤方式和原理分析
1.通过Theme切换主题 通过在setContentView之前设置Theme实现主题切换. 在styles.xml定义一个夜间主题和白天主题: <style name="Light ...
- Android Android-skin-support 换肤方案 原理讲解
文章目录 前言 思考一下 开源库中 找到答案 结束语 前言 请先查看这两篇文章 LayoutInflater.Factory Android xml解析到View的过程 Android 无需自定义Vi ...
- Android APK方式换肤实现原理
现在很多APP都有换肤的功能,例如微博,QQ等应用.这些应用的换肤原理是什么? 在用微博的时候,不难发现,当你要换肤时,先下载并安装一个皮肤apk,然后选择这个皮肤,就可以了. 这种方式就是把皮肤打包 ...
- Android 应用换肤方案的总结
虽然现在已经有很多不错的换肤方案,但是这些方案或多或少都存在自己的问题.在这篇文章中,我将对 Android 现有的一些动态换肤方案进行梳理,对其底层实现原理进行分析,然后对开发一个新的换肤方案的可能 ...
- 对 Android 应用换肤方案的总结
作者:me 虽然现在已经有很多不错的换肤方案,但是这些方案或多或少都存在自己的问题.在这篇文章中,我将对 Android 现有的一些动态换肤方案进行梳理,对其底层实现原理进行分析,然后对开发一个新的换 ...
- 换肤方案,换肤策略,App插件式换肤实现方案
UI换皮肤或白天黑夜模式,从产品上来看,是两种不同产品设计模式:白天黑夜模式只有两种模式:而换皮肤可以有多套,可以进行商业化,并盈利. 换肤的本质就是去替换资源文件.我们知道,Android应用程序由 ...
- Android App节日换肤
Android App节日换肤 Android App节日换肤 1原理 2使用方式 1在XML中给需要换肤的控件添加tag属性 2在Activity中使用 3还有疑问吧 3示例图 比如支付宝,饿了么, ...
- 最成熟的前端换肤方案(主题切换)
前言 在网上找了很多的换肤方案,其中我认为写的最好的也是有demo 的无疑是这篇,但是同时也发现了一些问题,就是太多方案不知道选哪个,而且没有做持久化处理,并且没有把图片切换的代码放在一起.我这边的需 ...
最新文章
- 为 ASP.NET Datagrid 创建自定义列
- 添加service到SystemService硬件服务
- php curl异步跳转,php curl批处理--可控并发异步
- echarts 怎么知道鼠标点击的哪根柱子
- 腾讯云云机安装dockers
- WDA演练一:用户登陆界面设计(一)
- LeetCode 438. 找到字符串中所有字母异位词(双指针+滑动窗口)
- Python 3.4中文编码
- python风险评分卡系统_《智能风控:Python金融风险管理与评分卡建模》(梅子行,毛鑫宇)【摘要 书评 试读】- 京东图书...
- 《重构》阅读笔记-代码的坏味道
- 阶段3 2.Spring_06.Spring的新注解_7 spring整合junit问题分析
- VS2019离线安装方法
- 什么是大数据,模式识别和人工智能算法实现
- Win系统 - 如何解决 Windows + P 键无法切换双显复制模式?
- 动态规划 之 完全背包
- eslint 修改standard规则
- 软件是如何驱动硬件的,代码是怎样对计算机实现控制的?
- [BZOJ5145] [Ynoi2018] 五彩斑斓的世界 [并查集][分块][摊还分析]
- 【GUI制作】tkinter-一款跨平台的简易GUI库
- 解决uniapp手机浏览器视频封面不显示问题
热门文章
- android 顶部导航栏 自定义,Android自定义NavigationController - 安卓自定义导航栏 --【WJ】...
- 微软Web Deploy在Windows Server 2003上的安装配置
- 浅谈LCA问题(最近公共祖先)(三种做法)
- Kali 用户名及密码找寻记
- 你可以不精通Vue,但一定要精通JS!
- 小迈网关对接平台——Jetlinks开源物联网平台
- java中获取绝对值的方法_Java完美判断绝对值的两种方法 | 彬菌
- 给在校大学生的一封信,助梦启航!
- 去除图片周围的像素填充
- LeetCode 289 Game of Life(生命游戏)(Array)