随着手机应用的成熟发展,市面上的应用已不在以简单的实现功能为目标了,它们反而会更加注重用户体验。我们常说的换肤(主题)功能——针对用户的喜好来提供一个可选的主题也是提高用户体验的方式之一。换肤功能不仅提高了用户体验并且还具有一定商业价值。许多大厂的app如QQ、网易云音乐都具有换肤的功能。那么我们来聊一聊Android的换肤功能如何实现。从原理出发,我们需要了解两点:

  1. 换肤的本质?
  2. 皮肤是什么?

1.换肤的本质

通过观察许多主流的app可以发现,它们的皮肤(主题)大多数都是要下载的,不可能把这些五花八门的样式全都都放在apk文件上,那样的话应用会很大。所以皮肤是通过网络下载的(当然可以有几套默认的皮肤),下载完之后点击一款皮肤就可以对整个应用的样式进行替换了,替换的是什么呢?当然就是资源文件啦,再简单一点说,就是去替换界面上的字体、颜色、背景、图片这些东西。 既然是替换资源文件,不管我们有多少个apk皮肤包,我们所定义的资源名称肯定要相同才行,不然无法做一个对应的关系,好比我们要替换drawable文件夹下的一张名为ic_bg.png的图片,那么新的图片也要这样去命名才能够正确替换。皮肤替换的过程就是加载皮肤包里面的资源文件,然后重新对每个view进行setXXX()这类操作。

2.皮肤是什么

上面说了,换肤的本质就是去替换资源文件。我们知道,Android应用程序由代码和资源组成。所以皮肤其实就是一个仅包含资源的apk文件

通过以上两点,可以对Android的换肤功能有一个大体的了解,这里我们可以做一个小结:

  • 皮肤是一个apk文件
  • 换肤三部曲:下载皮肤文件 ->获取资源 ->替换

抛砖引玉一番之后,下面我们来具体实现这个过程。 我们创建一个Demo来模拟换肤的实现流程,这个Demo很简单,先来看一下最终实现的效果(水印请忽略)。


我们要实现的效果是,点击右边的按钮应用蓝色的皮肤,点击左边的按钮恢复到默认的皮肤,这个界面的布局如下:

<RelativeLayoutxmlns: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:background="@drawable/ic_bg"android:layout_height="match_parent"tools:context=".MainActivity"><TextViewandroid:layout_width="match_parent"android:layout_height="match_parent"android:textSize="20sp"android:gravity="center"android:lineSpacingExtra="7dp"android:textColor="@color/mainText"android:text="Kotlin is now an official language on Android. It's expressive, concise, and powerful. Best of all, it's interoperable with our existing Android languages and runtime."app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintLeft_toLeftOf="parent"app:layout_constraintRight_toRightOf="parent"app:layout_constraintTop_toTopOf="parent"/><Buttonandroid:id="@+id/btn_default"android:layout_width="wrap_content"android:layout_height="wrap_content"android:background="@color/mainButton"android:layout_alignParentBottom="true"android:layout_marginStart="8dp"android:text="默认"/><Buttonandroid:id="@+id/btn_blue"android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_alignParentBottom="true"android:background="@color/mainButton"android:layout_marginEnd="8dp"android:layout_alignParentEnd="true"android:text="闷骚蓝"/></RelativeLayout>

其中ic.bg就是那张白色的背景图,引用的颜色资源在color.xml中定义。

<?xml version="1.0" encoding="utf-8"?>
<resources><color name="mainText">#181717</color><color name="mainButton">#d3e4db</color>
</resources>

1.创建一个皮肤包

看到上面的gif图便可知道,这里替换掉了根布局的背景、TextView的字体颜色和Button的背景颜色。接下来我们就创建这个“闷骚蓝”的皮肤。创建一个Android工程,命名为blue-skin。

这个工程,不需要创建任何的java类,只需要添加一张蓝色的背景图,命名为ic.bg,在color.xml中添加两个相同名称的颜色值。

<?xml version="1.0" encoding="utf-8"?>
<resources><color name="mainText">#03e0fd</color><color name="mainButton">#7a24e2</color>
</resources>

就是这么简单!直接选择Build apk生成皮肤包。生成皮肤包后,我们将扩展名更改成skin(也可以改成其他的),这样做的目的是为了防止系统的安装程序进行安装(毕竟啥都没)。然后将皮肤包拷贝到sd卡的根目录中去。这个过程就模拟了换肤过程中下载皮肤包到本地的过程,实际开发中应是通过网络下载,这里简化了这个步骤。

2.获取皮肤包的资源

为了方便,可以定义一个类似SkinManager的类来控制皮肤的加载、替换等过程,加载皮肤包资源的代码如下:

  public void loadSkin(String skinPath) {if (skinPath== null)return;new LoadTask().execute(skinPath);}
  class LoadTask extends AsyncTask<String, Void, Resources> {@Overrideprotected Resources doInBackground(String... paths) {try {if (paths.length == 1) {String skinPkgPath = paths[0];File file = new File(skinPkgPath);if (!file.exists()) {return null;}PackageManager mPm = context.getPackageManager();PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);skinPackageName = mInfo.packageName;AssetManager assetManager = AssetManager.class.newInstance();Method addAssetPath = assetManager.getClass().getMethod("addAssetPath",String.class);addAssetPath.invoke(assetManager, skinPkgPath);Resources superRes = context.getResources();Resources skinResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());saveSkinPath(skinPkgPath);return skinResource;}} catch (Exception e) {return null;}return null;}@Overrideprotected void onPostExecute(Resources resources) {super.onPostExecute(resources);mSkinResources = resources;if (mSkinResources != null) {isExternalSkin = true;notifySkinUpdate();}}}

loadSkin()*的内容比较简单,开启一个AsyncTask去加载皮肤包,这里面的参数输入的是皮肤包的全路径。我们知道Android程序的资源分为两大类,assert和resource,分别对应api中的AssertManager和Resource类,而AssertManager又在ResourcesImpl中,ResourcesImpl是Resource的一个具体实现类。通常在我们自己的工程中,可以通过调用context对象的getResource()方法获取Resource的示例,这是因为在应用启动的过程中就为我们创建了这个Resource对象。那如果我们要获取皮肤包的资源,就要去构造这个Resource对象了,Resource的构造方法如下:

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

Resource的构造方法中需要传入三个参数,重点看AssetManager ,由于AssetManager 的大多数api都是@hide的,包括public的构造方法,所以我们只能通过反射去创建一个AssetManager 对象,并通过反射去调它的addAssetPath 方法把皮肤包路径传进去。这一步是必须的,它可以让assetManager包含特定的PackageName的资源信息。Resource后面的两个参数是关于一些配置信息,影响不大,可以直接使用当前工程的Resource对象的配置。创建好Resource之后,就表示已经获取到皮肤包的资源了。

3.替换资源

既然我们已经获取到了Resource对象了,那么替换资源的工作就变得很简单了,但是,如何通知每个界面都进行替换呢?这一步还是比较简单的,通过观察者模式即可实现。在BaseActivity中去设置一个监听器,当加载完皮肤包资源的时候就可以去通知界面替换了。主要的问题是如何为每个view重新设置资源,如果去遍历整个view树再去找需要换肤的view显然是不太现实的,所以比较合适的做法就是,在创建每个View的时候,就把符合换肤条件的view收集起来,然后在需要换肤的时候再去遍历这个集合进行替换,简单分析一下这个过程。

首先,自定义一个创建view的工厂,收集需要换肤的view。

LayoutInflater.Factory

对于LayoutInflater的使用大家比较熟悉,调用LayoutInflater对象的*inflate()*方法即可将一个xml文件转成一个View对象。事实上,我们在activity中调用setContextView()去加载布局也是用到LayoutInflater这个类。

 @Overridepublic void setContentView(int resId) {//省略...ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);contentParent.removeAllViews();LayoutInflater.from(mContext).inflate(resId, contentParent);//省略...}

这个创建view的过程是系统默认实现的,我们完全可以提供一个Factory去创建view,LayoutInflater.Factory是LayoutInflater内部的一个接口,当创建view的时候,就会调用Factory中的onCreateView方法:

public View onCreateView(String name, Context context, AttributeSet attrs);

明白这一点,我们就可以去实现创建一个Factory,在onCreateView中去收集需要换肤的view。

Activity是实现了LayoutInflater.Factory的,所以也可以直接去重写BaseActivity的oncreateView方法来收集需要换肤的view。

这里先定义两个Bean类,SkinAttr是关于某个属性的信息。

public class SkinAttr {private String attrName;    //属性名(例如:background、textColor)private String attrType;    //属性类型(例如:drawable、color)private int resId;       //资源id值(例如:123)private String resName;     //资源名称(例如:ic_bg)public SkinAttr(String attrName, String attrType, String resName,int resId) {this.attrName = attrName;this.attrType = attrType;this.resId = resId;this.resName = resName;}/*** API* @return*/public String getAttrName() {return attrName;}public void setAttrName(String attrName) {this.attrName = attrName;}public String getAttrType() {return attrType;}public void setAttrType(String attrType) {this.attrType = attrType;}public int getResId() {return resId;}public void setResId(int resId) {this.resId = resId;}public String getResName() {return resName;}public void setResName(String resName) {this.resName = resName;}
}

定义一个SkinItem类将view和它的属性联系起来:

public class SkinItem {private View view;private List<SkinAttr> attrs;public SkinItem(View view, List<SkinAttr> attrs) {this.view = view;this.attrs = attrs;}public void apply() {if (view == null || attrs == null)return;for (SkinAttr attr : attrs) {String attrName = attr.getAttrName();String attrType = attr.getAttrType();String resName = attr.getResName();int resId = attr.getResId();if ("background".equals(attrName)) {if ("color".equals(attrType)) {view.setBackgroundColor(SkinManager.getInstance().getColor(resName,resId));} else if ("drawable".equals(attrType)) {view.setBackground(SkinManager.getInstance().getDrawable(resName,resId));}} else if ("textColor".equals(attrName)) {if (view instanceof TextView && "color".equals(attrType)) {((TextView) view).setTextColor(SkinManager.getInstance().getColor(resName,resId));}}}}}

最后我们创建MySkinFactory类并实现LayoutInflater.Factory来收集这些需要换肤的view的信息 :

public class MySkinFactory implements LayoutInflater.Factory {private List<SkinItem> skinItems = new ArrayList<>();@Overridepublic View onCreateView(String name, Context context, AttributeSet attrs) {View view = createView(name,context,attrs);if (view!=null){collectViewAttr(view,context,attrs);}return view;}private View createView(String name, Context context, AttributeSet attrs) {View view = null;try {if (-1 == name.indexOf('.')){ //不带".",说明是系统的Viewif ("View".equals(name)) {view = LayoutInflater.from(context).createView(name, "android.view.", attrs);}if (view == null) {view = LayoutInflater.from(context).createView(name, "android.widget.", attrs);}if (view == null) {view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs);}}else {    //带".",说明是自定义的Viewview = LayoutInflater.from(context).createView(name, null, attrs);}} catch (Exception e) {view = null;}return view;}private void collectViewAttr(View view,Context context, AttributeSet attrs) {List<SkinAttr> skinAttrs = new ArrayList<>();int attCount = attrs.getAttributeCount();for (int i = 0;i<attCount;++i){String attributeName = attrs.getAttributeName(i);String attributeValue = attrs.getAttributeValue(i);if (isSupportedAttr(attributeName)){if (attributeValue.startsWith("@")){    //必须是引用int resId = Integer.parseInt(attributeValue.substring(1));String resName = context.getResources().getResourceEntryName(resId);String attrType = context.getResources().getResourceTypeName(resId);skinAttrs.add(new SkinAttr(attributeName,attrType,resName,resId));SkinItem skinItem = new SkinItem(view, skinAttrs);if (SkinManager.getInstance().isExternalSkin()){skinItem.apply();}skinItems.add(skinItem);}}}}private boolean isSupportedAttr(String attributeName){return "background".equals(attributeName) || "textColor".equals(attributeName);}public void apply(){for (SkinItem item : skinItems) {item.apply();}}}

collectViewAttr()方法中去遍历这个view的属性名和属性值,如果属性名是background或者是textColor我们就认为这个view是需要换肤的(具体由什么来确定根据需要),如果这个属性的属性值是引用类型,那么我们就把这个view以及它对应的属性、属性值收集起来。创建完Factory之后,在BaseActivity中去设置:

 protected void onCreate(@Nullable Bundle savedInstanceState) {mSkinFactory = new MySkinFactory();getLayoutInflater().setFactory(mSkinFactory);super.onCreate(savedInstanceState);  SkinManager.getInstance().addSkinUpdateListener(this);}

设置Factory的代码要放在 super.onCreate(savedInstanceState) 之前,上面还有一句代码是设置皮肤更新的监听器,实现如下:

@Overridepublic void onSkinUpdate() {mSkinFactory.apply();}

调用的是mSkinFactory的apply()方法:

 public void apply() {if (view == null || attrs == null)return;for (SkinAttr attr : attrs) {String attrName = attr.getAttrName();String attrType = attr.getAttrType();String resName = attr.getResName();int resId = attr.getResId();if ("background".equals(attrName)) {if ("color".equals(attrType)) {view.setBackgroundColor(SkinManager.getInstance().getColor(resName,resId));} else if ("drawable".equals(attrType)) {view.setBackground(SkinManager.getInstance().getDrawable(resName,resId));}} else if ("textColor".equals(attrName)) {if (view instanceof TextView && "color".equals(attrType)) {((TextView) view).setTextColor(SkinManager.getInstance().getColor(resName,resId));}}}}

走到这一步就可以到清楚换肤的本质了,不过就是调用我们玩的行云流水的一系列setXXX()操作罢了。SkinManager中的getColor方法如下:

  public int getColor(String resName,int resId) {int originColor = context.getResources().getColor(resId);if(mSkinResources == null || !isExternalSkin){return originColor;}int newResId = mSkinResources.getIdentifier(resName, "color", skinPackageName);int newColor;try{newColor = mSkinResources.getColor(newResId);}catch(Resources.NotFoundException e){e.printStackTrace();return originColor;}return newColor;}

主要是使用Resource的getIdentifier()方法去获得某个颜色的资源id值(不是颜色值),注意这里的资源id就是皮肤包上的资源id了,然后再通过这个资源id值去获取对应的颜色值。如果没有找到,则返回默认的颜色值。getDrawable()方法和getColor()方法类型,这里不再赘述,文末会附上源代码。做到这里,我们就可以任意切换不同的皮肤包了,那如果要恢复默认的皮肤呢?没有问题,上马。哦不是。。。上代码:

 public void restoreDefaultTheme(){SPUtil.put(context, KEY, "");isExternalSkin= false;mSkinResources = null;notifySkinUpdate();}

将SkinResources置空即可。好了,我们来到主界面,为这两个按钮加上监听器,来测试一下:

public class MainActivity extends BaseActivity implements View.OnClickListener {private Button btnDefault;private Button btnBlue;private String skinPath;    //皮肤包路径@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);btnDefault = findViewById(R.id.btn_default);btnBlue = findViewById(R.id.btn_blue);btnDefault.setOnClickListener(this);btnBlue.setOnClickListener(this);skinPath = Environment.getExternalStorageDirectory().getAbsolutePath() +File.separator + "blue-skin.skin";  }@Overridepublic void onClick(View v) {switch (v.getId()) {case R.id.btn_default:SkinManager.getInstance().restoreDefaultTheme();break;case R.id.btn_blue:SkinManager.getInstance().loadSkin(skinPath);break;}}}

效果和上面的gif图是一致的。还有一个比较重要的地方,用户在下一次打开这个app应用的应是他最后一次选择的皮肤,不可能每次都要他选吧?那样会崩溃的。所以,在每次调用loadSkin() 加载皮肤包成功后,需要将此皮肤路径保存起来, 待下一次应用启动时就去加载此路径的皮肤,这样做的体验效果就会比较好些。这个过程可以放在Application的onCreate()去进行。这部分代码就不放了,源码中有。

总结

单从原理和实现手段来讲,Android的换肤功能还是非常简单的,但是本示例并没有给出一个通用的框架结构,只是针对换肤功能来进行说明,github上有不少优秀的换肤框架均可参考,本示例也是参考了Android-Skin-Loader这个框架所写,继续放一些参考博文:

1.Android换肤原理和Android-Skin-Loader框架解析

2.Android 在线换肤方案总结分享

Demo源码地址: https://github.com/ouchangxin/DynamicSkinDemo

更多安卓知识体系,请关注公众号:

Android 换肤之旅——主题切换相关推荐

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

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

  2. Android换肤总结

    文章目录 换肤方案 Theme换肤 Resouce换肤 2.拿到皮肤包Resource对象 3.标记需要换肤的View 4.缓存需要换肤的View 5.切换时即时刷新页面 6.制作皮肤包 UiMode ...

  3. Android-skin-support 一款用心去做的Android 换肤框架

    介绍 Github地址: https://github.com/ximsfei/Android-skin-support Android-skin-support: 一款用心去做的Android 换肤 ...

  4. Android换肤之Android-skin-support

    前言 之前做个APP需要用到换肤,在githup上面找了很久,终于找到一款功能强大.基本能够满足产品需求的换肤框架,那就是Android-skin-support,这个框架换肤功能很强大.不管是白天. ...

  5. Android 换肤demo,轻量快捷接入集成,判断是否夜间模式

    true为黑夜模式 //检查当前系统是否已开启暗黑模式 public static boolean getDarkModeStatus(Context context) {int mode = con ...

  6. android换肤哪个简单,Android换肤

    这是一个Android换肤的库,代码量极少,支持换肤的情况还比较多,提供了以下功能: 无需重启,一键换肤效率高 支持App内多套皮肤换肤 支持插件式动态换肤 支持Activity,Fragment,以 ...

  7. android换肤动画,Android换肤(二) — 插件式换肤

    ###前言 上节我们讲到了`Android-skin-support`库的应用内换肤,大家感兴趣的可以参看文章: [Android换肤(一) - 应用内换肤](http://www.demodashi ...

  8. Android 换肤方案详解(一)

    引言 在我们的开发中,也许有些项目会有换肤的需求,这个时候会比较头疼怎么做才能做到一键换肤呢?大家肯定是希望只要一行代码就能调用最好.下面我们先分析一下换肤的本质是什么? 原理 换肤,其本质无非就是更 ...

  9. Android 换肤原理分析

    当了解了一些知识,应该用文字记录它,再抽个时间再看它,永远记住它 Android 换肤的理论知识和文章已经很多了,这里记录一下自己对这块的理解.本文效果如下: 工程:一键换肤的快乐 一.换肤的由来 首 ...

最新文章

  1. linkedin databus介绍——监听数据库变化,有新数据到来时通知其他消费者app,新数据存在内存里,多份快照...
  2. CSS选择器笔记,element element和element element 的区别
  3. 【Golang 基础】Go 语言的程序结构
  4. 为nginx创建windows服务自启动
  5. Java开发WebService(使用Java-WS)
  6. mysql中使用安全等于 <=>
  7. React之总结Ref
  8. 周鸿祎:数字孪生时代 网络攻击影响力更甚核弹
  9. 60-40-020-序列化-自定义序列化
  10. HDU1265 Floating Point Presentation【水题】
  11. 2015 年总结 - 十年
  12. Windows Azure 云服务角色架构
  13. Java跨域问题以及如何使用Cors解决前后端 分离部署项目所遇到的跨域问题
  14. 基于滑模变结构的倒立摆控制系统matlab仿真
  15. hive建表语句comment 中文描述乱码
  16. Unity3D怪物基本AI
  17. Verilog rst
  18. 使用scrapy框架爬取腾讯招聘信息
  19. Ardupilot动力分配-混控部分分析
  20. Win10台式电脑网线正常但连不上网。

热门文章

  1. 赞不绝口!仅靠阿里P9分享的 Redis 工作手册,拿到60W年薪Offer
  2. mp4视频压缩怎么压缩到最小
  3. 视觉SLAM入门 -- 学习笔记 - Part2
  4. Fortran的堆栈溢出解决方法
  5. 谐振频率、截止频率、并联谐振、串联谐振、容抗、感抗计算公式,红色字体标注理解是否正确?如果不对,请指正
  6. MyBatis研习录(07)——MyBatis参数传递
  7. 【论文阅读】Prior Guided Feature Enrichment Network for Few-Shot Segmentation
  8. excel动态图表ppt_具有动态日期范围的Excel图表
  9. 【Unity】Gizmos:可视化Debug
  10. python实现微信消息群发和微信自动回复