#来自ウルトラマンゼロ(哉阿斯)
1 简介
市场上常见的 Android 换肤,主要有两种:
- 简单的 白天/黑夜 主题切换
- 多种主题切换,例如 QQ、网易云等
简单来说,Android 换肤就是对应用内的文字、 颜色、 图片、字体等的资源的更换。
2 换肤实现
换肤实现主要有两种方式:
- 静态换肤:在 APP内部放置多套相同的资源,进行资源的切换,最简单的例子就是 白天/黑夜主题切换。
- 动态换肤:运行时动态的加载皮肤包,皮肤包通常为网络下载的资源。
3 静态换肤
这里以实现白天和黑夜模式之间的切换为例子。
//1.colors.xml <?xml version="1.0" encoding="utf-8"?> <resources> <color name="colorPrimary">#FD7828</color> <color name="colorPrimaryDark">#90f85421</color> <color name="colorAccent">#F02421</color> <color name="cl_text">#FF1800</color> <color name="cl_bg">#FB987A</color> </resources> //2.新建 values-night/colors.xml 如需其他资源替换,例如图片,新建 drawable-night <?xml version="1.0" encoding="utf-8"?> <resources> <color name="colorPrimary">#AAAAAA</color> <color name="colorPrimaryDark">#D2D2D2</color> <color name="colorAccent">#F1F2F4</color> <color name="cl_text">#197AFF</color> <color name="cl_bg">#53B65B</color> </resources> //3.styles 修改主题,parent="Theme.AppCompat.DayNight <resources> <style name="AppTheme" parent="Theme.AppCompat.DayNight"> <!-- Customize your theme here. --> <item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorAccent">@color/colorAccent</item> </style> </resources> //4.App class App : Application() { private var config: SharedPreferences? = null private var edit: Editor? = null override fun onCreate() { super.onCreate() config = getSharedPreferences("config", Context.MODE_PRIVATE) edit = config?.edit() val isNight: Boolean = config?.getBoolean("isNight", false) ?: false if (isNight) { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) } else { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) } } } //5.activity_activity.xml <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/cl_bg" tools:context=".MainActivity"> <TextView android:id="@+id/tv_switch" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="切换" android:textColor="@color/cl_text" android:textSize="20sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> //6.MainActivity class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val tvSwitch = findViewById<TextView>(R.id.tv_switch) tvSwitch.setOnClickListener { val sp = getSharedPreferences("config", Context.MODE_PRIVATE) val isNight = sp.getBoolean("isNight", false) if (isNight) { //夜间 切换 日间 AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) sp.edit().putBoolean("isNight", false).apply() } else { //日间 切换 夜间 AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) sp.edit().putBoolean("isNight", true).apply() } } } }
4 动态换肤
运行时动态的加载皮肤包,皮肤包通常为网络下载的资源,核心技术要点:
- 加载皮肤包(网络下载)
- 拦截系统创建 View 的流程(hook)
- 获取需要替换资源的控件(自定义view属性,判断是否支持换肤)
- 加载替换资源
4.1 加载布局
//Activity setContentView(R.layout.activity_main) //AppCompatActivity#setContentView @Override public void setContentView(@LayoutRes int layoutResID) { //getDelegate() 获取委托类 -> AppCompatDelegateImpl getDelegate().setContentView(layoutResID); } //最终调用 AppCompatDelegateImpl#setContentView @Override public void setContentView(int resId) { ensureSubDecor(); ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content); contentParent.removeAllViews(); //解析布局 LayoutInflater.from(mContext).inflate(resId, contentParent); mAppCompatWindowCallback.getWrapped().onContentChanged(); } //LayoutInflater#inflate public View inflate(XmlPullParser parser, ...) { ... //根据 xml 标签头创建 View -> 例如 <TextView> -> TextView final View temp = createViewFromTag(root, name, inflaterContext, attrs); ... } //LayoutInflater#createViewFromTag //调用createViewByPrefix方法,判断是否是系统类,如果不是系统类,通过反射的方式来实现 View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) { if (name.equals("view")) { name = attrs.getAttributeValue(null, "class"); } //ignoreThemeAttr:是否忽略theme属性 if (!ignoreThemeAttr) { final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME); final int themeResId = ta.getResourceId(0, 0); if (themeResId != 0) { context = new ContextThemeWrapper(context, themeResId); } ta.recycle(); } try { //创建 View View view = tryCreateView(parent, name, context, attrs); if (view == null) { final Object lastContext = mConstructorArgs[0]; mConstructorArgs[0] = context; try { if (-1 == name.indexOf('.')) { view = onCreateView(context, parent, name, attrs); } else { view = createView(context, name, null, attrs); } } finally { mConstructorArgs[0] = lastContext; } } return view; } ... } //LayoutInflater#tryCreateView public final View tryCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) { if (name.equals(TAG_1995)) { // Let's party like it's 1995! return new BlinkLayout(context, attrs); } View view; if (mFactory2 != null) { //通过 mFactory2 创建 View -> Hook 点 实现由 AppCompatViewInflater#createView view = mFactory2.onCreateView(parent, name, context, attrs); } else if (mFactory != null) { view = mFactory.onCreateView(name, context, attrs); } else { view = null; } if (view == null && mPrivateFactory != null) { view = mPrivateFactory.onCreateView(parent, name, context, attrs); } return view; } //Factory2 是一个接口 public interface Factory2 extends Factory { @Nullable View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs); } //AppCompatViewInflater#createView final View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs, boolean inheritContext, boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) { switch (name) { case "TextView": view = createTextView(context, attrs); verifyNotNull(view, name); break; ... } return view; }
通过上面的代码我们知道最终通过 mFactory2.onCreateView 创建 View。
那么如何获取资源,其实就是通过 AssetManager 和 Resource 这两个类。
5 具体实现
1 Factory2
class SkinFactory : LayoutInflater.Factory2 { //我们并不需要代理所有的创建过程 所以很多事情都可以交给原先的Delegate类实现 lateinit var delegate: AppCompatDelegate //收集需要换肤的View var skinList = arrayListOf<SkinView>() override fun onCreateView( parent: View?, name: String, context: Context, attrs: AttributeSet ): View? { //因为我们并不需要实现所有流程 所以createView可以交给原先的代理类完成 var view = delegate.createView(parent, name, context, attrs) if (view == null) { mConstructorArgs[0] = context try { if (-1 == name.indexOf('.')) { view = createViewByPrefix(context, name, sClassPrefixList, attrs) } else { view = createViewByPrefix(context, name, null, attrs) } } catch (e: Exception) { e.printStackTrace() } } //收集需要换肤的View collectSkinView(context, attrs, view) return view } private fun collectSkinView(context: Context, attrs: AttributeSet, view: View?) { //这边判断了是否使用isSupport自定义属性 但是并没有对自定义View做很好的兼容 //对于自定义View 我们可以采用换肤接口来实现 具体实现可以大家自己尝试一下 val obtainStyledAttributes = context.obtainStyledAttributes(attrs, R.styleable.Skinable) val isSupport = obtainStyledAttributes.getBoolean(R.styleable.Skinable_isSupport, false) if (isSupport) { val skinView = SkinView() //找到支持换肤的view val len = attrs.attributeCount val attrMap = hashMapOf<String, String>() for (i in 0 until len) { //遍历所有属性 val attrName = attrs.getAttributeName(i) val attrValue = attrs.getAttributeValue(i) attrMap[attrName] = attrValue //缓存 } if (view != null) { skinView.view = view } skinView.attrsMap = attrMap skinList.add(skinView) } } fun changeSkins() { //遍历换肤类 实现换肤 for (skin in skinList) { skin.changeSkin() } } override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? { return null } class SkinView { lateinit var view: View lateinit var attrsMap: HashMap<String, String> fun changeSkin() { if (!TextUtils.isEmpty(attrsMap["background"])) { //属性名,例如,这个background,text,textColor.... val bgId = attrsMap["background"]!!.substring(1).toInt() //属性值,R.id.XXX ,int类型, // 这个值,在app的一次运行中,不会发生变化 val attrType = view.resources.getResourceTypeName(bgId) // 属性类别:比如 drawable ,color if (TextUtils.equals(attrType, "drawable")) { //区分drawable和color view.setBackgroundDrawable(SkinEngine.getDrawable(bgId)) //加载外部资源管理器,拿到外部资源的drawable } else if (TextUtils.equals(attrType, "color")) { view.setBackgroundColor(SkinEngine.getColor(bgId)) } } if (view is TextView) { if (!TextUtils.isEmpty(attrsMap["textColor"])) { val textColorId = attrsMap["textColor"]!!.substring(1).toInt() (view as TextView).setTextColor(SkinEngine.getColor(textColorId)) } } //那么如果是自定义组件呢 if (view is ZeroView) { //那么这样一个对象,要换肤,就要写针对性的方法了,每一个控件需要用什么样的方式去换,尤其是那种,自定义的属性,怎么去set, // 这就对开发人员要求比较高了,而且这个换肤接口还要暴露给 自定义View的开发人员,他们去定义 // .... } } } val mConstructorSignature = arrayOf(Context::class.java, AttributeSet::class.java) // val mConstructorArgs = arrayOfNulls<Any>(2) private val sConstructorMap = HashMap<String, Constructor<out View?>>() private val sClassPrefixList = arrayOf( "android.widget.", "android.view.", "android.webkit." ) private fun createViewByPrefix( context: Context, name: String, prefixs: Array<String>?, attrs: AttributeSet ): View? { var constructor: Constructor<out View?>? = sConstructorMap.get(name) var clazz: Class<out View?>? = null if (constructor == null) { try { if (prefixs != null && prefixs.size > 0) { for (prefix in prefixs) { clazz = context.classLoader.loadClass( if (prefix != null) prefix + name else name ).asSubclass(View::class.java) //控件 if (clazz != null) break } } else { if (clazz == null) { clazz = context.classLoader.loadClass(name) .asSubclass(View::class.java) } } if (clazz == null) { return null } constructor = clazz.getConstructor(*mConstructorSignature) } catch (e: java.lang.Exception) { e.printStackTrace() return null } constructor.isAccessible = true sConstructorMap.put(name, constructor) } val args = mConstructorArgs args[1] = attrs try { //通过反射创建View对象 return constructor.newInstance(*args) } catch (e: java.lang.Exception) { e.printStackTrace() } return null } }
2 自定义 Resource 类,加载类
object SkinEngine { lateinit var mContext: Context //外部皮肤包包名 private var mOutPkgName: String? = null //自定义Resource private var mOutResource: Resources? = null fun init(context: Context) { this.mContext = context } fun load(path: String) { val file = File(path) if (!file.exists()) { return } try { val pm = mContext.packageManager val packageInfo = pm.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES) mOutPkgName = packageInfo.packageName //通过反射获取AssetManager类 val assetManager: AssetManager = AssetManager::class.java.newInstance() //反射调用addAssetPath方法 将皮肤Apk资源加入到AssetManger中 val method = assetManager::class.java.getMethod("addAssetPath", String::class.java) method.invoke(assetManager, path) mOutResource = Resources( assetManager, mContext.resources.displayMetrics, mContext.resources.configuration ) } catch (e: Exception) { e.printStackTrace() } } fun getDrawable(resId: Int): Drawable? { if (mOutResource == null) { return ContextCompat.getDrawable(mContext, resId) } //通过id找到type和name 然后再找到资源 val resName = mOutResource!!.getResourceEntryName(resId) val outResId = mOutResource!!.getIdentifier(resName, "drawable", mOutPkgName) return if (outResId == 0) { ContextCompat.getDrawable(mContext, resId) } else mOutResource!!.getDrawable(outResId) } fun getColor(resId: Int): Int { if (mOutResource == null) { return resId } val resName = mOutResource!!.getResourceEntryName(resId) val outResId = mOutResource!!.getIdentifier(resName, "color", mOutPkgName) return if (outResId == 0) { resId } else mOutResource!!.getColor(outResId) } }
3 Hook AppCompatActivity
open class BaseActivity : AppCompatActivity() { var ifAllowChangeSkin = true lateinit var skinFactory: SkinFactory var currentSkin: String? = null override fun onCreate(savedInstanceState: Bundle?) { if (ifAllowChangeSkin) { skinFactory = SkinFactory() skinFactory.delegate = delegate val layoutInflater = LayoutInflater.from(this) if (layoutInflater.factory == null) { LayoutInflaterCompat.setFactory2(layoutInflater, skinFactory) } } super.onCreate(savedInstanceState) } override fun onResume() { super.onResume() if (null != currentSkin) { changeSkin(currentSkin!!) } } fun changeSkin(path: String) { if (ifAllowChangeSkin) { var file = File(getExternalFilesDir(""), path) SkinEngine.load(file.absolutePath) skinFactory.changeSkins() currentSkin = path } } }
6 总结
- 通过网络加载皮肤包 .skin 、.apk 等
- 拦截系统创建 View 的流程(hook),hook Factory2
- 获取需要替换资源的控件(自定义view属性,判断是否支持换肤)
- 让 App 具有加载外部资源的功能,使用 Resource 和 AssetManager
7 其他
上面只是一个粗显的方案,成熟的换肤方案,可以使用第三方,例如这个库Android-skin-support ,这些换肤方案实现思想都是大同小异。
Android-skin-support 核心思想:
- 监听 APP 所有 Activity 的生命周期(registerActivityLifecycleCallbacks())
- 在每个 Activity 的 onCreate() 方法调用时 setFactory(),设置创建 View 的工厂,将创建 View 交给SkinCompatViewInflater去处理。
- 库中重写了系统的控件(比如 View 对应于库中的 SkinCompatView),实现换肤接口(接口里面只有一个applySkin()方法),表示该控件是支持换肤的,并且将这些控件在创建之后收集起来,方便随时换肤。
- 在库中自己写的控件里面去解析出一些特殊的属性(比如background, textColor),并将其保存起来。
- 在切换皮肤的时候,遍历一次之前缓存的 View,调用其实现的接口方法 applySkin(),在 applySkin() 中从皮肤资源(可以是从网络或者本地获取皮肤包)中获取资源,获取资源后设置其控件的 background 或 textColor等,就可实现换肤.