【Android】换肤技术讲解

el/2024/5/23 2:38:26

主题,是许多APP必备的一个功能,用户可以根据自己的喜好,来切换具有个性的主题,同时能让我们的APP更具把玩性。这篇博文就来聊聊皮肤切换的原理,效果图如下:
这里写图片描述

这里为了便于理解,在换肤的时候,只是简单切换背景图片,文件颜色和组件背景色
这篇博文将用到一下知识点:

  • classLoader:实例化控件
  • PackageManager:拿到插件的包信息
  • 反射:拿到插件的resource
  • LayoutInflaterFactory:解析xml

一、思路

首先通过LayoutInflaterCompat的setFactory方法设置自定义的LayoutInflaterFactory,并实现onCreateView,我们可以在该方法中解析xml的每一个节点(即view ),先通过组件名创建对应的view ,再遍历每一个view的 attrs属性和值,并以map保存,以便后续调用(即皮肤资源的切换)。
我们知道,在Android中是通过resource来获取资源的,若能获取插件的resource对象,那么就可以获取其图片等资源,到达换肤的目的,说得简单点,换肤就是换resource和packName。
在拿到插件的resource之后,就可以通过resource Id 来给每一个view设置其属性(如background)
当然,道理想必大家都懂,show me your code ~

二、偷梁换柱,换掉应用的LayoutInflaterFactory

我们需要持切换皮肤的组件,因此创建SkinFactory类实现LayoutInflaterFactory接口,并实现该接口中的方法onCreateView

 /*** Hook you can supply that is called when inflating from a LayoutInflater.* You can use this to customize the tag names available in your XML* layout files.** @param parent The parent that the created view will be placed* in; <em>note that this may be null</em>.* @param name Tag name to be inflated.* @param context The context the view is being created in.* @param attrs Inflation attributes as specified in XML file.** @return View Newly created view. Return null for the default*         behavior.*/View onCreateView(View parent, String name, Context context, AttributeSet attrs);

显然这是一个hook, 执行LayoutInflater.inflate()的时候调用,如上所述,我们可以通过该方法获取每一个节点的属性和值(即资源id),资源类型(drawable 、color 等)。先简单介绍这四个参数:

  • parent:即当前节点的父类节点,可能为null
  • name :节点名,列如 TextView
  • context :该执行过程的上下文
  • attrs:该节点的属性集合,例如 background属性

那么,我们怎么通过节点来创建对应的组件对象呢?我们都知道在android.widget包下的Button在布局文件中的节点名只有Button,并不是完整的包路径,例如

    <Button
        android:layout_width="match_parent"android:layout_height="wrap_content"/>

以及android.view包下的SurfaceView等等。

<SurfaceView
        android:layout_width="wrap_content"android:layout_height="wrap_content" />

想必读者明白列举以上的用意了,对,我们需要先对获取到的节点名字进行处理,判断获取到的节点名是系统组件,还是自定义组件,从而构建完整的class name 。如下代码

    private static final String[] preFixList = {"android.widget.","android.view.","android.webkit."};  //这些都是系统组件@Overridepublic View onCreateView(View parent, String name, Context context, AttributeSet attrs) {View view = null;if (name.indexOf(".") == -1) {//系统控件for (String prix : preFixList) {view = createView(context, attrs, prix + name);if (null != view) {break;}}} else {//自定义控件view = createView(context, attrs, name);}if (null != view) {parseSkinView(view, context, attrs);}return view;}

这里需要我们返回一个view,即该组件对应的view,既然能拿到组件对应的class name,那就好办,直接通过classloader去load一个class即可

    //创建一个viewprivate View createView(Context context, AttributeSet attrs, String name) {try {//实例化一个控件Class clarr = context.getClassLoader().loadClass(name);Constructor<? extends View> constructor =clarr.getConstructor(new Class[]{Context.class, AttributeSet.class});constructor.setAccessible(true);return constructor.newInstance(context, attrs);} catch (ClassNotFoundException e) {e.printStackTrace();} catch (NoSuchMethodException e) {e.printStackTrace();} catch (IllegalAccessException e) {e.printStackTrace();} catch (InstantiationException e) {e.printStackTrace();} catch (InvocationTargetException e) {e.printStackTrace();}return null;}

在拿到该控件后,需要遍历其需要替换值的属性,例如background,存放在list集合中。

    //找到需要换肤的控件private void parseSkinView(View view, Context context, AttributeSet attrs) {List<SkinInterface> attrList = new ArrayList<>();for (int i = 0; i < attrs.getAttributeCount(); i++) {//拿到属性名String attrName = attrs.getAttributeName(i);String attrValue = attrs.getAttributeValue(i);int id =-1;String entryName ="";String typeName ="";SkinInterface skinInterface = null ;switch (attrName) {case "background"://需要进行换肤id = Integer.parseInt(attrValue.substring(1));entryName = context.getResources().getResourceEntryName(id);typeName = context.getResources().getResourceTypeName(id);skinInterface = new BackgroundSkin(attrName,id,entryName,typeName);break;case "textColor":id = Integer.parseInt(attrValue.substring(1));entryName = context.getResources().getResourceEntryName(id);typeName = context.getResources().getResourceTypeName(id);skinInterface = new TextSkin(attrName,id,entryName,typeName);break;default:break;}if(null != skinInterface){attrList.add(skinInterface);}}SkinItem skinItem = new SkinItem(attrList,view);map.put(view,skinItem);//在这里进行应用,判断是皮肤资源还是本地资源skinItem.apply();}

为了方便属性的替换,这里用SkinItem对象来持有view和view对应的属性集合list。

  class SkinItem {public SkinItem(List<SkinInterface> attrList, View view) {this.attrList = attrList;this.view = view;}public List<SkinInterface> attrList;public View view;//更新组件资源,调用skinInterface 的实现类public void apply() {for (SkinInterface skinInterface : attrList) {skinInterface.apply(view);}}}

在进行皮肤切换的时候,有设置background的,有设置textColor的,但他们都需要以下参数

  • 组件的属性名称,例如 background
  • 组件引用资源的id (integer 类型)
  • 组件引用资源的名称,例如 app_icon
  • 组件引用资源的类型,例如 drawable

所以我们这里可以抽象出一个类SkinInterface,所有需要换肤的实现类都继承该类

public abstract class SkinInterface {String attrName;int refId = 0;String attrValueName;String attrType;public SkinInterface(String attrName, int refId, String attrValueName, String attrType) {this.attrName = attrName;this.refId = refId;this.attrType = attrType;this.attrValueName = attrValueName;}/*** 执行具体切换工作 * @param view 作用对象*/public abstract void apply(View view);
}

列如SkinInterface的继承类 TextSkin


public class TextSkin extends SkinInterface {public TextSkin(String attrName, int refId, String attrValueName, String attrType) {super(attrName, refId, attrValueName, attrType);}@Overridepublic void apply(View view) {if(view instanceof TextView){TextView textView = (TextView)view ;textView.setTextColor(SkinManager.getInstance().getColor(refId));}}
}

还有BackgroundSkin类的实现

public class BackgroundSkin extends SkinInterface{private static final String TAG = "BackgroundSkin";public BackgroundSkin(String attrName, int refId, String attrValueName, String attrType) {super(attrName, refId, attrValueName, attrType);}@Overridepublic void apply(View view) {if("color".equals(attrType)){view.setBackgroundColor(SkinManager.getInstance().getColor(refId));}else if("drawable".equals(attrType)){view.setBackgroundDrawable(SkinManager.getInstance().getDrawable(refId));}}
}

最后在SkinFactory中提供一个更新的方法,来实现资源的替换工作

   public void upDate() {for(View view : map.keySet()){if(null == view){continue;}map.get(view).apply();}}

总之一句话,SkinFactory 负责创建view并获取其属性名和值,以及后续的切换资源工作

三、resource的中心枢纽——SkinManager

上一节在讲到皮肤切换具体实现类的时候,涉及到SkinManager对象,他就是resource的主要负责人,负责返回组件所需要的资源。
回想一下,我们是如何在activity中获取资源的?是不是通过getResources().get……方法?显然我们需要获取插件的resource对象,才能拿到插件里的资源,先来看看resource的构造函数

  @Deprecatedpublic Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {this(null);mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());}

这里需要三个参数?有什么办法,那就给他咯~
首先看AssetManager ,他有两个构造函数,一个是hide的,一个是private的,均不能直接new 出来,这个好办~~

AssetManager  assetManager = AssetManager.class.newInstance();

AssetManager 有一个addAssetPath方法可以通过文件路径来加载资源,但也是hide状态,怎么办?easy !反射啦~~

 Method method = AssetManager.class.getMethod("addAssetPath", String.class);method.invoke(assetManager, path);

这样我们就顺利滴拿到了插件的AssetManager对象,剩下的两个参数就直接使用宿主项目上下文的resource的默认值即可

Resources resources = context.getResources();
Resources  skinResource = new Resources(assetManager, resources.getDisplayMetrics(),resources.getConfiguration());

于是乎,就这样顺利的拿到了插件的resource对象,但是我们还需要获取插件的包名

PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
String skinPackage = packageInfo.packageName;

获取资源不是通过resource吗,为什么还需要插件的packageName 呢?接着往下看

在获取resource对象后,就可以提供接口给其他类获取资源了,例如获取color

 public int getColor(int refId) {if (null == skinResource) {return refId;}String resName = context.getResources().getResourceEntryName(refId);int realId = skinResource.getIdentifier(resName, "color", skinPackage);int color;if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {color = skinResource.getColor(realId, null);} else {color = skinResource.getColor(realId);}return color;}

看到这里,想必大家知道packageName的用处了吧?就是获取插件同资源名对应的id,然后再通过插件的id 获取对应的资源,获取drawable同理

   public Drawable getDrawable(@DrawableRes int refId) {Drawable drawable = ContextCompat.getDrawable(context, refId);if (null == skinResource) {return drawable;}String resName = context.getResources().getResourceEntryName(refId);int resId = skinResource.getIdentifier(resName, "drawable", skinPackage);return skinResource.getDrawable(resId);}

这样SkinManager 就创建完成了

四、创建基类

大家说得好,万物基于…..基类~~,这里我们需要创建一个抽象的SkinBaseActivity,凡是需要进行换肤的activity都要继承该类

public abstract class SkinBaseActivity extends Activity {private SkinFactory skinFactory ;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);skinFactory = new SkinFactory();//设置当前activity解析xml的工厂类LayoutInflaterCompat.setFactory(getLayoutInflater(),skinFactory );//LayoutInflaterFactory}//手动更换皮肤public void upDate(){skinFactory.upDate();}}

然后在MainActivity中继承该类,并将SkinManager初始化

public class MainActivity extends SkinBaseActivity {private static final String TAG = "MainActivity ";@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);SkinManager.getInstance().init(this);setContentView(R.layout.activity_main);}
}

在进行皮肤切换的时候执行(要确保file的路径正确,否则会出错)

   public void change(View view) {String path = new File(Environment.getExternalStorageDirectory(), "skin.apk").getAbsolutePath();SkinManager.getInstance().loadSkin(path);upDate();}

这里要注意加上权限,并到权限管理中心给该应用读写权限

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

那么,如何恢复默认皮肤呢?很简单,将SKinmanager中的resource,packageName替换为当前应用的即可

   public void back(View view) {SkinManager.getInstance().setSkinResource(getResources());upDate();}

五、run it!

创建一个module,按照宿主apk的资源名重新建立新的资源即可,选择module,打包成apk,再将apk copy到手机的根目录下,在as中切换到宿主apk,将宿主项目打包到手机,即可
至此,换肤技术就讲解完毕
源码下载


http://www.ngui.cc/el/5281478.html

相关文章

【android】插件化技术原理详解

作为移动端的黑科技&#xff0c;插件化技术一直受大厂的青睐。插件化技术有减少宿主Apk体积&#xff0c;可以独立更新&#xff0c;模块化开发等优点&#xff0c;让宿主APP极具扩展性。那么&#xff0c;现在就来聊聊其中的技术实现&#xff0c;国际惯例&#xff0c;先上效果图 这…

Android单元测试全解

自动化测试麻烦吗&#xff1f;说实在&#xff0c;麻烦&#xff01;有一定的学习成本。但是&#xff0c;自动化测试有以下优点&#xff1a; 节省时间&#xff1a;可以指定测试某一个activity&#xff0c;不需要一个个自己点单元测试&#xff1a;既然Java可以进行单元测试&#…

Android采用pm实现静默安装(降级安装)的解决方案

最近在做一个apk分析器&#xff0c;里面可以解析系统中所有安装app的信息&#xff0c;并提供组内开发的apk文件下载、静默安装&#xff08;包括降级安装&#xff09;&#xff0c;其中在降级安装中难度较大&#xff0c;在Android4.4与Android 8的解决方案不同&#xff0c;其他版…

cannot open line '/dev/tty.usbserial' for r/w resource busy

在进行串口调试的时候,需要在iterm上查看log信息只需要输入以下命令 screen /dev/tty.usbserial-gggggggg1 115200有时候会遇到以下问题 cannot open line /dev/tty.usbserial for r/w resource busy提示资源被占用了&#xff0c;感觉有点像进程被占用是一个问题。Google了一…

android适配右到左布局注意事项

呜呼&#xff0c;伊朗的项目终于做完了&#xff0c;大部分都是在整理右到左布局的需求。好在android sdk 从API17&#xff08;Android4.2&#xff09;开始支持右到左布局的需求&#xff0c;但是会有很多坑需要去填。   Android中的大部分组件是支持右到左布局的&#xff0c;只…

【flutter】把Google官方的历史时间demo跑起来

“其实我并不喜欢追求新技术。flutter是Google出的&#xff1f;真香&#xff5e;“ 引言 其实跨平台的痛&#xff0c;我真的没有体会到&#xff0c;毕竟我司不做ios平台。但是如果&#xff0c;flutter有可能成为新系统的开发框架&#xff0c;还是值得学习一下的&#xff0c;尤…

用python实现自动化翻译

“爬Google翻译还是需要点技巧的“ 引言 在做全球应用中&#xff0c;处理每个国家的翻译是必不可少的&#xff0c;也是最棘手的问题。为了保证翻译是正确的&#xff0c;这里需要借用Google翻译的接口&#xff0c;为此&#xff0c;特意写了一个python脚本来对接google翻译 _。 …

mac平台反编译apk,获取源码,androidmanifest和资源文件

“提供一个反编译apk的工具” 在window上反编译apk是比较容易的事情&#xff0c;但是在mac上就需要稍微费力一些&#xff0c;这里为了方便大家&#xff0c;特意提供了一个python脚本&#xff0c;实现快速反编译apk 注意&#xff1a;需要python环境哦&#xff5e;&#xff5e; …

详细解剖adb命令

写在前面 开始想将标题设置为“深度解剖adb命令&#xff0c;后来犹豫了一下&#xff0c;“深度”&#xff0c;何为 “深度” &#xff1f; 如同“精通”一词&#xff0c;不敢随意挥写。但是本文会尽力列举adb的相关命令和说明&#xff0c;那就“详细”一词吧&#xff01; 本文会…

ElasticSearch 索引详解

还在用mysql进行全文索引&#xff1f;来试试强大的搜素引擎ElasticSearch 吧&#xff01; 注&#xff1a;以下文档基于ElasticSearch 7.X版本&#xff0c;与老版本会有些出入 1、格式说明 elasticSearch的数据交互接口是基于http协议实现的&#xff0c;基本格式如下&#xff1…