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

换肤的本质?
皮肤是什么?
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">#181717</color><color name="mainButton">#d3e4db</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();}}}

41
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源码地址: GitHub - ouchangxin/DynamicSkinDemo: Android动态切换皮肤demo

原文链接:https://blog.csdn.net/weixin_38261570/article/details/82079540

Android主题换肤实现原理与Demo相关推荐

  1. Android主题换肤 无缝切换

    作者 _SOLID 关注 2016.04.17 22:04* 字数 4291 阅读 23224评论 123喜欢 679 今天再给大家带来一篇干货. Android的主题换肤 ,可插件化提供皮肤包,无需 ...

  2. Android主题换肤 无缝切换 你值得拥有

    链接:https://www.jianshu.com/p/af7c0585dd5b 天再给大家带来一篇干货. Android的主题换肤 ,可插件化提供皮肤包,无需Activity的重启直接实现无缝切换 ...

  3. Android主题换肤实现

    本系列文章主要是对一个Material Design的APP的深度解析,主要包括以下内容 基于Material Design Support Library作为项目整体框架.对应博文:Android ...

  4. Android 主题换肤的开源库

    Android 主题换肤的开源库(插件化换肤) 新增夜间模式的简洁实现方式,不需要再去单独创建一个皮肤包(目前处于beta版本) 夜间模式实现方式 前提条件还是每个使用到的资源必须是引用的,不能是具体 ...

  5. Android 主题换肤技术方案分析

    写在前面 Android TV 电视开发,主题换肤,我感觉有两种层级的方式,一种是 系统级,另一种 是应用级, 我记得很早在 Linux 桌面开发的时候,我们之前的公司在GTK+上也实现了一套换肤UI ...

  6. Android三种换肤方案原理及Demo

    方案一:使用主题文件 定义换肤资源 在values/下新建一个xml文件,比如theme_attrs.xml,然后定义换肤的资源类型 <?xml version="1.0" ...

  7. Android 动态换肤技术原理 | 实践 及总结

    实现的效果图 动态换肤一般实现的原理 对页面需要换肤的View进行标记 在Activity#setContentView()加载view时获取到标记的view(后面会说是要怎么获取到) 创建一个Lib ...

  8. android 状态栏 背景色_技术一面:说说Android动态换肤实现原理

    换肤分为动态换肤和静态换肤 静态换肤 这种换肤的方式,也就是我们所说的内置换肤,就是在APP内部放置多套相同的资源.进行资源的切换. 这种换肤的方式有很多缺点,比如, 灵活性差,只能更换内置的资源.a ...

  9. Android Android-skin-support 换肤方案 原理讲解

    文章目录 前言 思考一下 开源库中 找到答案 结束语 前言 请先查看这两篇文章 LayoutInflater.Factory Android xml解析到View的过程 Android 无需自定义Vi ...

  10. 安卓app开发教程!Android动态换肤实现原理解析,值得收藏!

    开头 Android开发,假如开始没有任何的开发经验的话, 千万不要着急,不要想着在短时间内就把一个语言学习好, 因为你之前没有任何的学习经验, 在这个过程中需要有耐心地学习完JAVA的基础知识, 然 ...

最新文章

  1. Shell脚本:向磁盘中批量写入数据
  2. 软件工程学习笔记《四》需求分析
  3. 前端学习(2376):项目初始化
  4. Shell判断参数是否为数字的6种方法(是否为整形)
  5. 代码jquery分享一款jquery加载csv文件的代码
  6. 神奇的go语言(image网站开发)
  7. 4412 使用小度wifi
  8. 使用Grafana搭建监控系统
  9. Java多重选择switch
  10. A Comprehensive Measurement Study of Domain Generating Malware 原文翻译
  11. 计算机主机如何睡眠,win7怎样设置电脑休眠_w7电脑设置休眠的详细步骤
  12. 【技巧】ApiPost生成word格式的接口文档,接口文档合并操作
  13. SQL Server 基本开发规范
  14. 互联网金融诈骗不缺受害者, 有人刚被3M坑了又投入CA
  15. 本周白银价格走势仍关注美经济数据
  16. 计算机键盘打出来都是英语大写怎么办,电脑键盘切换大小写怎么变成CapsLock和Shift键...
  17. 内网渗透系列:内网隧道之nps
  18. java自行车租凭系统项目包_基于jsp的自行车租赁-JavaEE实现自行车租赁 - java项目源码...
  19. python列表中元素移动_python list中元素依次向前移动一位的方法
  20. CCD 芯片与 CMOS 芯片的主要参数有哪些?

热门文章

  1. GOOGLE搜索局域网聊天软件局域网聊天软件
  2. idea 中静态图片资源无法导入
  3. 值得收藏的199条经典民间偏方
  4. python interface_面向对象编程语言中的接口(Interface)
  5. [LINUX服務器搭建套餐]2.安裝mysql
  6. 人都“爆”了有这么好的东西《vtdakz.com》顶硬了!
  7. nginx(二十七)长连接和短连接
  8. matlab 光斑质心算法,一种光斑提取及其质心确定的方法技术
  9. 分布式 Git - 为项目做贡献
  10. 延时delay1s程序 c语言,汇编语言软件延时1s的实现方法