android换肤整理
2019-03-07 本文已影响5人
有点健忘
来源这里https://www.jianshu.com/p/4c8d46f58c4f
整理下,方便以后使用,刚写完简单测试没啥问题,以后发现问题再修改
前言
核心思路就是用到这个方法
这个出来很久了,我只记得几年前用的时候就简单的修改页面字体的大小
LayoutInflaterCompat.setFactory2(layoutInflater, object : LayoutInflater.Factory2
换肤的方法
- 如果只是简单的,固定的,那么其实本地写几套主题就可以实现了
也就是这种,布局里使用 ?attr/主题里的字段
?attr/colorPrimary
然后不同的主题指定不同的颜色,图片,大小就行了
<item name="colorPrimary">@color/colorPrimary</item>
- 就是根据开头帖子的内容,加载一个本地的apk文件,获取到他的resource
然后利用下边的方法获取到资源,这种打包成apk的方便网络下载,可以随时添加皮肤
mOutResource?.getIdentifier(resName, type, mOutPkgName)
工具类
本工具类使用到了LiveData,方便通知其他页面刷新,并且是用kt写的
- LiveDataUtil
根据一个string的key值,存储相关的LiveData,完事获取LiveData也是通过这个key值。
换肤操作里主要用了最后两个方法,
getResourcesLiveData 获取LiveData<Resources>
observerResourceChange:注册观察者
import android.arch.lifecycle.LifecycleOwner
import android.arch.lifecycle.MutableLiveData
import android.arch.lifecycle.Observer
import android.content.res.Resources
object LiveDataUtil {
private val bus = HashMap<String, MutableLiveData<Any>>()
fun <T> with(key: String, type: Class<T>): MyLiveData<T> {
if (!bus.containsKey(key)) {
bus[key] = MyLiveData(key)
println("create new============$key")
}
return bus[key] as MyLiveData<T>
}
fun with(key: String): MyLiveData<Any> {
return with(key, Any::class.java)
}
fun observer(key: String,lifecycleOwner: LifecycleOwner,observer: Observer<Any>){
with(key).observe(lifecycleOwner,observer)
}
fun <T> observer(key: String,type:Class<T>,lifecycleOwner: LifecycleOwner,observer: Observer<T>){
with(key,type).observe(lifecycleOwner,observer)
}
fun remove(key:String,observer: Observer<Any>){
if(bus.containsKey(key)){
bus[key]?.removeObserver(observer)
}
}
fun clearBus(){
bus.keys.forEach {
bus.remove(it)
}
}
class MyLiveData<T> (var key:String):MutableLiveData<T>(){
override fun removeObserver(observer: Observer<T>) {
super.removeObserver(observer)
if(!hasObservers()){
bus.remove(key)//多个页面添加了观察者,一个页面销毁这个livedata还需要的,除非所有的观察者都没了 ,才清除这个。
}
println("remove===========$key=====${hasObservers()}")
}
}
fun getResourcesLiveData():MutableLiveData<Resources>{
return with(SkinLoadUtil.resourceKey,Resources::class.java)
}
fun observerResourceChange(lifecycleOwner: LifecycleOwner,observer: Observer<Resources>){
getResourcesLiveData().observe(lifecycleOwner,observer)
}
}
- SkinLoadUtil
根据传入的apk的sdcard路径,通过反射获取这个apk的assetManager,进而生成对应的resource
拿到resource也就可以拿到这个apk的资源文件了
public int getIdentifier(String name, String defType, String defPackage)
import android.content.Context
import android.graphics.drawable.Drawable
import android.content.res.AssetManager
import android.content.pm.PackageManager
import android.content.res.Resources
import java.io.File
class SkinLoadUtil private constructor() {
lateinit var mContext: Context
companion object {
val instance = SkinLoadUtil()
val resourceKey = "resourceKey"
}
fun init(context: Context) {
this.mContext = context.applicationContext
}
private var mOutPkgName: String? = null// TODO: 外部资源包的packageName
private var mOutResource: Resources? = null// TODO: 资源管理器
fun getResources(): Resources? {
return mOutResource
}
fun load(path: String) {//path 是apk在sdcard的路径
val file = File(path)
if (!file.exists()) {
return
}
//取得PackageManager引用
val mPm = mContext.getPackageManager()
//“检索在包归档文件中定义的应用程序包的总体信息”,说人话,外界传入了一个apk的文件路径,这个方法,拿到这个apk的包信息,这个包信息包含什么?
val mInfo = mPm.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES)
try {
mOutPkgName = mInfo.packageName//先把包名存起来
val assetManager: AssetManager//资源管理器
//TODO: 关键技术点3 通过反射获取AssetManager 用来加载外面的资源包
assetManager = AssetManager::class.java.newInstance()//反射创建AssetManager对象,为何要反射?使用反射,是因为他这个类内部的addAssetPath方法是hide状态
//addAssetPath方法可以加载外部的资源包
val addAssetPath = assetManager.javaClass.getMethod("addAssetPath", String::class.java)//为什么要反射执行这个方法?因为它是hide的,不直接对外开放,只能反射调用
addAssetPath.invoke(assetManager, path)//反射执行方法
mOutResource = Resources(assetManager, //参数1,资源管理器
mContext.getResources().getDisplayMetrics(), //这个好像是屏幕参数
mContext.getResources().getConfiguration())//资源配置//最终创建出一个 "外部资源包"mOutResource ,它的存在,就是要让我们的app有能力加载外部的资源文件
LiveDataUtil.getResourcesLiveData().postValue(mOutResource)
} catch (e: Exception) {
e.printStackTrace()
}
}
//清楚加载的皮肤,替换为当前apk的resource,这里的context使用Application的
fun clearSkin(context: Context) {
mOutResource = context.resources
mOutPkgName = context.packageName
LiveDataUtil.getResourcesLiveData().postValue(mOutResource )
}
fun getResId(resName: String, type: String): Int {
return mOutResource?.getIdentifier(resName, type, mOutPkgName) ?: 0
}
//type 有可能是mipmap
fun getDrawable(resName: String, type: String = "drawable"): Drawable? {
val res = getResId(resName, type)
if (res > 0) {
return mOutResource?.getDrawable(res);
}
return null;
}
fun getColor(resName: String): Int {
val res = getResId(resName, "color")
if (res <= 0) {
return -1
}
return mOutResource?.getColor(res) ?: -1
}
fun getDimen(resName: String, original: Int): Int {
val res = getResId(resName, "dimen")
if (res <= 0) {
return original
}
return mOutResource?.getDimensionPixelSize(res) ?: original
}
fun getString(resName: String): String? {
val res = getResId(resName, "string")
if (res <= 0) {
return null
}
return mOutResource?.getString(res)
}
}
- CustomFactory
import android.content.Context
import android.content.res.Resources
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.support.v7.app.AppCompatDelegate
import android.text.TextUtils
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import java.util.*
class CustomFactory(var delegate: AppCompatDelegate) : LayoutInflater.Factory2 {
private var mOutResource: Resources? = null// TODO: 资源管理器
fun resourceChange(resources: Resources?) {
mOutResource = resources
loadSkin()
}
private var inflater: LayoutInflater? = null
private var startContent = false;//我们的view都是在系统id为android:id/content的控件里的,所以在这之后才处理。
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
if (parent != null && parent.id == android.R.id.content) {
startContent = true;
}
var view = delegate.createView(parent, name, context, attrs);
if (!startContent) {
return view
}
if (view == null) {
//目前测试两种情况为空:
// 1.自定义的view,系统的或者自己写的,看xml里,带包名的控件
//2. 容器类组件,继承ViewGroup的,比如LinearLayout,RadioGroup,ScrollView,WebView
//不为空的,就是系统那些基本控件,
if (inflater == null) {
inflater = LayoutInflater.from(context)
}
val index = name.indexOf(".")
var prefix = ""
if (index == -1) {
if (TextUtils.equals("WebView", name)) {
prefix = "android.webkit."
} else {
prefix = "android.widget."
}
}
try {
view = inflater!!.createView(name, prefix, attrs)
} catch (e: Exception) {
//api26以下createView方法有bug,里边用到了一个context是空的,所以这里进行异常处理,通过反射,重新设置context
try {
reflect(context, attrs)
view = inflater!!.createView(name, prefix, attrs)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
if (view != null && !TextUtils.equals("fragment", name)) {
val map = hashMapOf<String, String>()
repeat(attrs.attributeCount) {
val name = attrs.getAttributeName(it)
val value = attrs.getAttributeValue(it)
// println("attrs===========$name==${value}")
if (value.startsWith("@")) {//我们只处理@开头的资源文件
map.put(name, value)
}
mOutResource?.apply {
//切换皮肤以后,部分ui才开始加载,这时候就要用新的resource来加载了
handleKeyValue(view, name, value)
}
}
views.put(view, map)
}
println("$name==========$view")
return view;
}
private fun reflect(mContext: Context, attrs: AttributeSet) {
try {
var filed = LayoutInflater::class.java.getDeclaredField("mConstructorArgs")
filed.isAccessible = true;
filed.set(inflater, arrayOf(mContext, attrs))
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun onCreateView(name: String?, context: Context?, attrs: AttributeSet?): View? {
return null
}
val views = hashMapOf<View, HashMap<String, String>>()
private fun handleKeyValue(view: View, key: String, value: String) {
if (value.startsWith("@")) {
var valueInt = 0
try {
valueInt = value.substring(1).toInt()
} catch (e: Exception) {
//处理@style/xxxx这种,类型转换就错了,我们也不需要处理这种。
}
if (valueInt <= 0) {
return
}
val type = view.resources.getResourceTypeName(valueInt)
//type:资源类型,也可以说是res下的那些目录表示的,drawable,mipmap,color,layout,string
val resName = view.resources.getResourceEntryName(valueInt)
//resName: xxxx.png ,那么那么就是xxxx, string,color,就是资源文件里item里的name
// println("key/value===$key / $value=====type;$type====${resName}")
//下边这个处理下background属性,src(ImageView用的),可以是color,也可以是图片drawable或mipmap
when (type) {
"drawable", "mipmap" -> {
when (key) {
"background" -> {
getDrawable(resName, type) {
view.background = it
}
}
"src" -> {
if (view is ImageView) {
getDrawable(resName, type) {
view.setImageDrawable(it)
}
}
}
}
}
"color" -> {
when (key) {
"background" -> {
getColor(resName) {
view.setBackgroundColor(it)
}
}
"src" -> {
if (view is ImageView) {
getColor(resName) {
view.setImageDrawable(ColorDrawable(it))
}
}
}
}
}
}
//处理下TextView的字体颜色,大小,文字内容,有啥别的可以继续添加
if (view is TextView) {
when (key) {
"textColor" -> {
getColor(resName) {
view.setTextColor(it)
}
}
"textSize" -> {
getDimen(resName, view.resources.getDimensionPixelSize(valueInt)) {
view.setTextSize(it.toFloat())
}
}
"text" -> {
getString(resName) {
view.text = it
}
}
}
}
//下边这2个,二选一即可,一个回调,一个空的方法,用来处理自己app里自定义view,
//使用回调就不需要重写这个类了,不用回调那就重写这个类处理handleCustomView方法
customHandleCallback?.invoke(view, key, valueInt, type, resName)
handleCustomView(view, key, valueInt, type, resName)
}
}
var customHandleCallback: ((view: View, key: String, valueInt: Int, type: String, resName: String) -> Unit)? = null
open fun handleCustomView(view: View, key: String, valueInt: Int, type: String, resName: String) {
//这个是app里自定义的类,简单处理下。
// if (view is TextViewWithMark) {
// if (TextUtils.equals("sage_mark_bg_color", key)) {
// getColor(resName) {
// view.markBgColor = it
// }
// }
// if (TextUtils.equals("sage_mark_content", key)) {
// getString(resName) {
// view.markContent = it
// }
// }
// }
}
fun getDrawable(resName: String, type: String = "drawable", action: (Drawable) -> Unit) {
val drawable = SkinLoadUtil.instance.getDrawable(resName, type)
drawable?.apply {
action(this)
}
}
fun getColor(resName: String, action: (Int) -> Unit) {
val c = SkinLoadUtil.instance.getColor(resName)
if (c != -1) {
action(c)
}
}
fun getDimen(resName: String, original: Int, action: (Int) -> Unit) {
val size = SkinLoadUtil.instance.getDimen(resName, original)
action(size)
}
fun getString(resName: String, action: (String) -> Unit) {
val str = SkinLoadUtil.instance.getString(resName)
str?.apply {
action(this)
}
}
fun loadSkin() {
println("loadSkin===========${views.size}")
views.keys.forEach {
val map = views.get(it) ?: return
val view = it;
map.keys.forEach {
val value = map.get(it)
println("loadSin:$view==========$it==$value")
handleKeyValue(view, it, value!!)
}
}
}
}
- 使用
Application的onCreate方法里添加如下代码,初始化context
SkinLoadUtil.instance.init(this)
然后在activity的基类里添加如下的代码
open var registerSkin=true// 决定页面是否支持换肤
var customFactory:CustomFactory?=null//,如果你要继承这个类重写代码的话,那这里改成子类名字即可
override fun onCreate(savedInstanceState: Bundle?) {
if(registerSkin){
customFactory= CustomFactory(delegate).apply {
resourceChange(SkinLoadUtil.instance.getResources())
// customHandleCallback={view, key, valueInt, type, resName ->
//回调处理自定义的view,
// }
}
LayoutInflaterCompat.setFactory2(layoutInflater,customFactory!!)
LiveDataUtil.observerResourceChange(this, Observer {
customFactory?.resourceChange(it)
})
}
super.onCreate(savedInstanceState)
}
下边是点击换肤按钮的操作
主要就是获取到apk在sdcard的路径,传进来即可,我这里放在根目录了,实际中随意调整。
这种好处是皮肤可以随时从服务器下载下来用。
btn_skin1.setOnClickListener {
SkinLoadUtil.instance.load(File(Environment.getExternalStorageDirectory(),"skin1.apk").absolutePath)
}
btn_skin2.setOnClickListener {
SkinLoadUtil.instance.load(File(Environment.getExternalStorageDirectory(),"skin2.apk").absolutePath)
}
btn_clear.setOnClickListener {
//还原为默认的皮肤,清除已加载的皮肤
SkinLoadUtil.instance.clearSkin(activity!!.applicationContext)
}
- 新建个工程
把不需要的目录啥都删了,就留下res下的即可
然后就是添加和宿主app要换的资源,
比如图片,就弄个同名的放在对应目录下
比如下边这里要改的,修改为新的值就行了
<string name="skin1_show">修改后的</string>
<color name="item_index_text_color">#0000ff</color>
<dimen name="item_index_title_size">14sp</dimen>
记得把工程style.xml下默认添加的主题都删了,这样build.gradle下关联的库就可以删光了。打包出来的apk就只有资源文件的大小了。
然后点击makeProject
image.png
然后在下图位置就能拿到apk拉,当然了你要带签名打包apk也随意。
image.png