Android App切换主题的实现原理剖析

现在越来越多的APP都加入了主题切换功能或者是日间模式和夜间模式功能切换等,这些功能不仅增加了用户体验也增强了用户好感,众所周知QQ和网易新闻的APP做的用户体验都非常好,它们也都有日间模式和夜间模式的主题切换功能。体验过它们的主题切换后你会发现大部分效果是更换相关背景图片、背景颜色、字体颜色等来完成的,网上这篇文章对主题切换讲解的比较不错,今天我们从源码的角度来学习一下主题切换功能,如果你对这块非常熟悉了,请跳过本文(*^__^*)

在开始讲解主题切换之前我们先看一下LayoutInflater吧,大家都应该对LayoutInflater的使用非常熟悉了(如果你对它的使用还不是很清楚请自行查阅)。LayoutInflater的使用场合非常多,常见的比如在Adapter的getView()方法中,在Fragment中的onCreateView()中使用等等,总之如果我们想要把对应的layout.xml文件渲染成对应的View层级视图,离开LayoutInflater是不行的,那么我们如何获取LayoutInflater实例并用其来渲染成对应的View实例对象呢?一般有以下几种方式:

调用Context.getSystemService()方法

LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); View rootView = inflater.inflate(R.layout.view_layout, null);

直接使用LayoutInflater.from()方法

LayoutInflater inflater = LayoutInflater.from(context); View rootView = inflater.inflate(R.layout.view_layout, null);

在Activity下直接调用getLayoutInflater()方法

LayoutInflater inflater = getLayoutInflater(); View rootView = inflater.inflate(R.layout.view_layout, null);

使用View的静态方法View.inflate()

rootView = View.inflate(context, R.layout.view_layout, null);

以上4种方式都可以渲染出一个View实例出来但也都是借助LayoutInflater的inflate()方法来完成的,我们先看一下方式2中LayoutInflater.from()是怎么做的,代码如下:

/** * Obtains the LayoutInflater from the given context. */ public static LayoutInflater from(Context context) { LayoutInflater LayoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); if (LayoutInflater == null) { throw new AssertionError("LayoutInflater not found."); } return LayoutInflater; }

LayoutInflater.from()方法只不过是对方式1的一层包装,最终还是通过调用Context的getSystemService()方法获取到LayoutInflater实例对象,然后通过返回的LayoutInflater实例对象调用其inflate()方法来完成对xml布局文件的渲染并生成相应的View对象。通过和方式1对比你会发现,这两种方式中的Context如果是相同的那么获取的LayoutInflater对象应该是同一个。然后我们在看一下方式3中的实现部分,方式3是在Activity中直接调用Activity的getLayoutInflater()方法,源码如下:

/** * Convenience for calling * {@link android.view.Window#getLayoutInflater}. */ public LayoutInflater getLayoutInflater() { return getWindow().getLayoutInflater(); }

通过源码发现Activity的getLayoutInflater()方法辗转调用到了getWindow()的getLayoutInflater()方法,getWindow()方法返回一个Window类型的对象,其中Window为抽象类在Android中该类的实现类是PhoneWindow,也就是说getWindow().getLayoutInflater()方法最终调用的是PhoneWindow的getLayoutInflater()方法,我们看一下PhoneWindow类中该方法的实现过程,代码如下:

/** * Return a LayoutInflater instance that can be used to inflate XML view layout * resources for use in this Window. * * @return LayoutInflater The shared LayoutInflater. */ @Override public LayoutInflater getLayoutInflater() { return mLayoutInflater; }

在PhoneWindow类中直接返回了mLayoutInflater对象,那么mLayoutInflater是在何时何地完成初始化的呢?我们继续查看mLayoutInflater的初始化在哪完成的,通过查看代码发现是在PhoneWindow的构造方法中完成初始化的,代码如下:

public PhoneWindow(Context context) { super(context); mLayoutInflater = LayoutInflater.from(context); }

我们暂且不关心PhoneWindow是何时何地完成初始化的,我们只关心mLayoutInflater的初始化也是直接调用LayoutInflater.from()方法来完成的,这种方式和方式2是一样的,都是借助传递进来的context调用其getSystemService()方法获取到LayoutInflater实例,也就是说只要PhoneWindow中传递进来的context和方式1、方式2是相同的,那么可以确定获取到的mLayoutInflater的实例就是同一个。接着我们看方式4的通过调用View的静态方法inflate()的内部流程是怎样的,代码如下:

/** * Inflate a view from an XML resource. This convenience method wraps the {@link * LayoutInflater} class, which provides a full range of options for view inflation. * * @param context The Context object for your activity or application. * @param resource The resource ID to inflate * @param root A view group that will be the parent. Used to properly inflate the * layout_* parameters. * @see LayoutInflater */ public static View inflate(Context context, int resource, ViewGroup root) { LayoutInflater factory = LayoutInflater.from(context); return factory.inflate(resource, root); }

在View的inflate()静态方法中先是根据传递进来的context通过LayoutInflater.from()方法来获取一个LayoutInflater实例对象,然后调用LayoutInflater的inflate()方法来完成把layout.xml布局文件渲染成对应的View层级视图然后返回。

通过对以上代码的分析我们可以得以下出结论:前边说的无论以哪种方式来渲染View视图都会先获取到LayoutInflater的实例,然后通过调用该实例的inflate()方法把xml布局文件渲染出相应的View层级视图,而获取LayoutInflater实例是需要Context的,那也就是说如果传入的Context对象是同一个那么获取的LayoutInflater实例也是相同的。这也是我用不小的篇幅从源码的角度说明这一点的原因所在。

现在我们已经清楚了渲染View是由LayoutInflater来完成的,那么在Activity的onCreate()方法中通过调用setContentView()为当前Activity设置显示内容是不是也是通过LayoutInflater的inflater()方法完成的呢?我们接着看代码,看看Activity的setContentView()里是如何操作的,代码如下:

/** * Set the activity content from a layout resource. The resource will be * inflated, adding all top-level views to the activity. * * @param layoutResID Resource ID to be inflated. *  * @see #setContentView(android.view.View) * @see #setContentView(android.view.View, android.view.ViewGroup.LayoutParams) */ public void setContentView(int layoutResID) { getWindow().setContentView(layoutResID); initActionBar(); }

setContentView()方法中只是做了一个中转,接着是调用Window实例的setContentView()方法,刚刚也说过Window为抽象类,它的实现类为PhoneWindow,那也就是最终调用的是PhoneWindow的setContentView()方法,我们看一下PhoneWindow的setContentView()方法,源码如下:

@Override public void setContentView(int layoutResID) { if (mContentParent == null) { installDecor(); } else { mContentParent.removeAllViews(); } // 这里同样是调用了LayoutInflater的inflate()方法 mLayoutInflater.inflate(layoutResID, mContentParent); final Callback cb = getCallback(); if (cb != null && !isDestroyed()) { cb.onContentChanged(); } }

从源码可以看到在PhoneWindow的setContentView()方法中也同样使用的是LayoutInflater的inflate()方法。到这里我们就可以总结出结论:无论是我们自己渲染View还是说为Activity设置显示内容都是借助LayoutInflater来完成的,而获取LayoutInflater最终都是通过Context.getSystemService()来得到的,如果Context相同,那么获取的LayoutInflater的实例是相同的。

好了,用了不少篇幅讲解了有关LayoutInflater的知识都是给主题切换功能做铺垫的,那怎么利用LaoutInflater来完成主题切换功能呢?别着急,我们再看一下LayoutInflater的源码,打开LayoutInflater的源码你会发现,其内部定义了Factory,Factory2等接口,这两个接口是干嘛的了?其实他们俩功能是一样的,Factory2是对Factory的完善,先看Factory的定义说明,代码如下:

public interface Factory { /** * 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. *  *

* Note that it is good practice to prefix these custom names with your * package (i.e., com.coolcompany.apps) to avoid conflicts with system * names. * * @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. */ public View onCreateView(String name, Context context, AttributeSet attrs); }

接口Factory中定义了onCreateView()方法,该方法返回一个View实例。我们看看该方法的说明,大致意思是说:当我们使用LayoutInflater来渲染View的时候此方法可以支持做Hook操作,我们可以在xml布局文件中使用自定义标签,需要注意的是不要使用系统名字。那么这里究竟该如何使用了?我们先梳理一下使用LayoutInflater渲染View的流程,以方式2为例子做说明吧,在方式2中rootView是由inflater.inflate()方法生成的,我们进入inflate()方法中看一下其内部的执行流程,代码如下:

/** * Inflate a new view hierarchy from the specified xml resource. Throws * {@link InflateException} if there is an error. *  * @param resource ID for an XML layout resource to load (e.g., * R.layout.main_page) * @param root Optional view to be the parent of the generated hierarchy. * @return The root View of the inflated hierarchy. If root was supplied, * this is the root View; otherwise it is the root of the inflated * XML file. */ public View inflate(int resource, ViewGroup root) { return inflate(resource, root, root != null); }

inflate()方法中什么都没做直接调用了其同名的重载方法inflate(),我们接着往里跟进,代码如下:

/** * Inflate a new view hierarchy from the specified xml resource. Throws * {@link InflateException} if there is an error. *  * @param resource ID for an XML layout resource to load (e.g., * R.layout.main_page) * @param root Optional view to be the parent of the generated hierarchy (if * attachToRoot is true), or else simply an object that * provides a set of LayoutParams values for root of the returned * hierarchy (if attachToRoot is false.) * @param attachToRoot Whether the inflated hierarchy should be attached to * the root parameter? If false, root is only used to create the * correct subclass of LayoutParams for the root view in the XML. * @return The root View of the inflated hierarchy. If root was supplied and * attachToRoot is true, this is root; otherwise it is the root of * the inflated XML file. */ public View inflate(int resource, ViewGroup root, boolean attachToRoot) { if (DEBUG) System.out.println("INFLATING from resource: " + resource); XmlResourceParser parser = getContext().getResources().getLayout(resource); try { return inflate(parser, root, attachToRoot); } finally { parser.close(); } }

在该inflate()方法中通过调用getContext().getResource().getLayout()的方式根据传递进来的布局资源ID生成一个XmlResourceParser实例对象parser,这个parser就是用来解析布局文件的(有关在Java中如何解析xml文件,请自行查阅,这里不再介绍),根据资源ID获取到解析器parser后调用了参数有XmlPullParser的重载方法inflate(),我们继续进入该代码中看一下执行流程,代码如下:

/** * Inflate a new view hierarchy from the specified XML node. Throws * {@link InflateException} if there is an error. *

* Important For performance * reasons, view inflation relies heavily on pre-processing of XML files * that is done at build time. Therefore, it is not currently possible to * use LayoutInflater with an XmlPullParser
over a plain XML file at runtime. * * @param parser XML dom node containing the description of the view * hierarchy. * @param root Optional view to be the parent of the generated hierarchy (if * attachToRoot is true), or else simply an
object that * provides a set of LayoutParams values for root of the returned * hierarchy (if attachToRoot is false.) * @param attachToRoot Whether the inflated hierarchy should be attached to * the root parameter? If false, root is only
used to create the * correct subclass of LayoutParams for the root view in the XML. * @return The root View of the inflated hierarchy. If root was supplied and * attachToRoot is true, this is root; otherwise it is the root of * the inflated XML file. */ public
View inflate(XmlPullParser parser, ViewGroup root, boolean attachToRoot) { synchronized (mConstructorArgs) { final AttributeSet attrs = Xml.asAttributeSet(parser); Context lastContext = (Context)mConstructorArgs[0]; mConstructorArgs[0] = mContext; View result
= root; try { // Look for the root node. int type; while ((type = parser.next()) != XmlPullParser.START_TAG && type != XmlPullParser.END_DOCUMENT) { // Empty } if (type != XmlPullParser.START_TAG) { throw new InflateException(parser.getPositionDescription()
+ ": No start tag found!"); } final String name = parser.getName(); if (DEBUG) { System.out.println("**************************"); System.out.println("Creating root view: " + name); System.out.println("**************************"); } if (TAG_MERGE.equals(name))
{ if (root == null || !attachToRoot) { throw new InflateException(" can be used only with a valid " + "ViewGroup root and attachToRoot=true"); } rInflate(parser, root, attrs, false); } else { // Temp is the root view that was found in the xml View temp; if
(TAG_1995.equals(name)) { temp = new BlinkLayout(mContext, attrs); } else { temp = createViewFromTag(root, name, attrs); } ViewGroup.LayoutParams params = null; if (root != null) { if (DEBUG) { System.out.println("Creating params from root: " + root); } //
Create layout params that match root, if supplied params = root.generateLayoutParams(attrs); if (!attachToRoot) { // Set the layout params for temp if we are not // attaching. (If we are, we use addView, below) temp.setLayoutParams(params); } } if (DEBUG)
{ System.out.println("-----> start inflating children"); } // Inflate all children under temp rInflate(parser, temp, attrs, true); if (DEBUG) { System.out.println("-----> done inflating children"); } // We are supposed to attach all the views we found (int
temp) // to root. Do that now. if (root != null && attachToRoot) { root.addView(temp, params); } // Decide whether to return the root that was passed in or the // top view found in xml. if (root == null || !attachToRoot) { result = temp; } } } catch (XmlPullParserException
e) { InflateException ex = new InflateException(e.getMessage()); ex.initCause(e); throw ex; } catch (IOException e) { InflateException ex = new InflateException( parser.getPositionDescription() + ": " + e.getMessage()); ex.initCause(e); throw ex; } finally
{ // Don't retain static reference on context. mConstructorArgs[0] = lastContext; mConstructorArgs[1] = null; } return result; } }

该方法代码有点长,但也是我们今天要讲解的重点,主要逻辑就是递归解析布局文件并创建View树结构,然后返回该View树结构。该段代码中先通过Xml类的静态方法生成一个AttributeSet实例对象attrs,AttributeSet对象我们应该很熟悉,里边主要包含了相关属性的键值对。接下来就是通过parser解析器循环遍历查询布局文件的根节点,若没有查询到就会抛出异常。遍历完成之后获取到根节点名字存储在变量name中,然后进行判断。如果当前根节点标签名字是mege标签就走if()语句,否则进入else语句。由于我们在布局文件中没有使用merge标签,所以直接进入else语句中。进入else语句后,先定义值为null的临时变量temp,接着开始做判断,如果当前根节点标签名字为BlinkLayout就进入if语句,因为我们没有使用这个标签就进入else语句,在else语句中通过调用createViewFromTag()来创建一个View并赋值给temp。接下来又是条件判断,因为传递进来的root为空,所以跳过if(root
!= null)的判断语句,接着执行rInflate()方法(该方法是来循环渲染包含的所有的子视图的)。执行完成后返回temp的值。

我们进入crateViewFromTag()方法中看一下里边的执行流程,代码如下:

View createViewFromTag(View parent, String name, AttributeSet attrs) { if (name.equals("view")) { name = attrs.getAttributeValue(null, "class"); }  if (DEBUG) System.out.println("******** Creating view: " + name);  try { View view; if (mFactory2 != null) view = mFactory2.onCreateView(parent, name, mContext, attrs); else if (mFactory != null) view = mFactory.onCreateView(name, mContext, attrs); else view = null;  if (view == null && mPrivateFactory != null) { view = mPrivateFactory.onCreateView(parent, name, mContext, attrs); }  if (view == null) { if (-1 == name.indexOf('.')) { view = onCreateView(parent, name, attrs); } else { view = createView(name, null, attrs); } }  if (DEBUG) System.out.println("Created view is: " + view); return view;  } catch (InflateException e) { throw e;  } catch (ClassNotFoundException e) { InflateException ie = new InflateException(attrs.getPositionDescription() + ": Error inflating class " + name); ie.initCause(e); throw ie;  } catch (Exception e) { InflateException ie = new InflateException(attrs.getPositionDescription() + ": Error inflating class " + name); ie.initCause(e); throw ie; } }

方法createViewFromTag()主要流程就是通过标签名name来创建相应View实例对象并返回。在该方法中首先根据Factory实例对象来创建View,如果创建成功就直接返回,否则执行系统默认创建View流程。这里需要强调一点,LayoutInflater内部定义了一个boolean类型的mFactorySet开关,其值默认值为false,当我们调用过setFactory()或者是setFactory2()后mFactorySet为true,若我们再次调用这俩方法时会抛出异常,也就是说每一个LayoutInflater实例对象只能赋值一次Factory,若再想赋成其他值只能通过反射先把mFactorySet的值置为false防止抛异常。系统默认创建View流程是先通过判断标签名称中有没有包含".",如果没有包含就把标签名添加前缀"android.view.",最终调用LayoutInflater的createView()方法,注意该方法是public并且是final类型的,是系统默认的创建View的方式,创建完成之后返回该view。

到这里我们已经清楚了LayoutInflater根据xml布局文件来渲染View视图的主要流程:先是通过布局文件的资源ID创建一个XmlResourceParser解析器对象parser,再是利用parser递归解析xml布局文件,然后根据解析出的标签名来创建相关View,最终返回层级视图View。如果LayoutInflater中设置了Factory,那么在创建每一个View时都会调用该Factory的onCreateView()方法,这个方法就是我们的入口点,如果想在每一个View创建之前做点处理,只需要在Factory的onCreateView()方法中做相关逻辑操作...

既然已经找到了创建View的切入口,那怎么样才能实现主题切换功能呢?主题切换通常是更改背景以及文字颜色等,在做更改之前要先知道哪些View需要更改,那我们怎么才能知道布局文件中的View需要做主题切换了?自定义属性是推荐的做法,当布局文件中使用了自定义属性就表示该View是做主题切换功能的,在该View创建后把它装入集合中,当需要主题切换时循环遍历该集合更改View相关属性就好了...

下面是简书上分享的一个切换主题的Demo:

Android主题换肤 无缝切换

字数4065 阅读6837 评论47 喜欢249

今天再给大家带来一篇干货。 Android的主题换肤 ,可插件化提供皮肤包,无需Activity的重启直接实现无缝切换,可高仿网易云音乐的主题换肤。

这个链接是本次的Demo打包出来的样本SkinChangeDemo,可以去下载下来先试试效果,皮肤文件需放到存储卡的根目录下。

关于Android的主题换肤都是个老生常谈的问题了。网上给出的方案也是层出不穷,最近我也是很想去了解这方面的知识,所以我去搜一下就会有一大堆介绍这方面的文章,但是最后的结果都是不尽人意的,有的确实是给出了一些比较好的解决方案,但是没有一个实质性的Demo可以参考,所以也只能是纸上谈兵罢了,有的呢,确实是给出了一个参考的Demo但是最后的结果不是我想要的。关于Android的换肤方案技术的总结,这篇文章还是挺有参考价值的Android换肤技术总结。感兴趣的同学可以去了解下,就当做是一个知识的普及。

今天我要实现的一个换肤方案是基于github上的这个开源框架Android-Skin-Loader这个框架的换肤机制是使用动态加载的机制去加载皮肤包里面的内容,无需Acitvity重启即可实现皮肤的实时更换,皮肤包是可以与原安装包相分离的,需要自己定做(这个皮肤包其实也就是一个普通的Android项目,只是只有资源文件没有类文件而已),这样做的好处就是可以在线提供皮肤包供用户去下载,也可以大大的减少安装包的体积,同时也很好的实现了插件化。其实这个框架是可以拿来直接来用的,直接几行代码基本上就可以解决Android的主题换肤,但是作为一个程序员怎么可以只是简单的知道怎么用就行了吗?如果真是这样就真的太low了。遇到一个好的开源项目我们至少需要把他的源码大致看一下,走一下基本的流程,了解一下他的基本原理,这样我们在技术上才会有所提升。本文实现的Demo是基于在我前段时间发布的Android
Material Design 兼容库的使用详解
一文中的Demo改进的。最后实现的App也是MaterialDesign的设计风格。

好了说了这么多,通过本文你可以学到什么,这个可能是大家比较关心的一点

  • 设计出一个基于MaterialDesign风格的App
  • 自己实现一个主题换肤的框架
  • 高仿网易云音乐的主题换肤(ps:其实本来我想以这个作为标题的,这样做也可以增加流量,可我不想单纯的做个标题党,给大家带来干货才是最重要的)
  • 让你的技术更上一层楼(这个说了也是白说)

说了这么久可能就会有人按捺不住了:我是来看干货的,不是来这听你瞎BB的。不要急干货马上来。如果实在感觉枯燥可以直接跳到文末去看源码。下面先来几张效果图来爽一下

网易云音乐换肤界面

这个是网易云音乐的换肤界面,他提供了几个默认的,也提供了可以在线下载的主题,他的切换效果还是非常赞的,用过这个软件的同学肯定是知道的。学习完本文后就可以做出类似于这个换肤效果。

Demo最终效果图

这个动态图是最终我们这个Demo实现的效果,这个Demo总体来说还是比较简单的,只提供了三种皮肤。实现了一个基本的换肤效果,主要还是用于拿来学习使用。当然更复杂的换肤基于这个Demo也是可以办到的,这里主要还是去讲解原理。

在介绍之前还需要先给大家普及一下LayoutInflaterFactory相关的知识。如果已经知道了这方面的知识点,下面这一段可以直接略过。

对于LayoutInflater大家可能都不太陌生,当你需要把xml文件转化成对应View的时候就必须用到它,我想对于他怎么使用的就不用我介绍了。LayoutInflater 提供了setFactory(LayoutInflater.Factory factory)和setFactory2(LayoutInflater.Factory2 factory)两个方法可以让你去自定义布局的填充(有点类似于过滤器,我们在填充这个View之前可以做一些额外的事,但不完全是),Factory2 是在API 11才添加的。

他们提供了下面的方法让你去重写。在这里面你完全可以自己去定义去创建你所想要的View,如果在你在重写的方法中返回null的话,就会以系统默认的方式去创建View。

View onCreateView(String name, Context context, AttributeSet attrs)//LayoutInflater.Factory
View onCreateView(View parent, String name, Context context, AttributeSet attrs)//LayoutInflater.Factory2

LayoutInflater都被设置了一个默认的Factory,Activity 是实现了LayoutInflater.Factory接口的,因此在你的Activity中直接重写onCreateView就可以自定义View的填充了。

下面这句是对LayoutInflater.Factory一个比较好的理解

Inflating your own custom views, instead of letting the system do it

这个也是这个Demo其中的一个比较重要技术点。如果有想更详细了解的文末会有参考链接。



下面就正式开始介绍怎么去做这个主题换肤吧。

先来看看这个Demo的项目结构:

项目结构图

至于xRecyclerView可以不用管,这里我们用不到(这是之前用到的,与本次无关),他只是一个RecyclerView的一个扩展框架,支持下拉刷新和上拉加载,是一个在github上的一个开源项目。

这里我们直接来看看lib_skinloader这个库吧(这里面的内容大部分是来源于Android-Skin-Loader这个框架,我只做了部分修改,主要是适配AppCompatActivity,原框架是基于最初的Activty开发的,在这里再次感谢开源作者),这个库就是今天所讲的核心内容

lib_skinloader包结构图

我们都知道在Android中如果想去获取资源文件都必须通过Resources去获取。这个库的核心思想就是动态的去加载第三方包里面的包,获取到其Resources然后以获取到的这个Resources去获取第三方包里面的资源内容,最后设置到我们有需响应皮肤更改的View上。

这里我就只介绍load和base两个包,其他包的内容在讲解的时候会涉及到

1.load包

我们先来看看这个load包里面的内容(其实这里就是今天核心内容的核心)。

load包

里面有两个类文件:SkinInflaterFactory、SkinManager

我们先来看看SkinManager的实现,直接跳到load方法

 public void load(String skinPackagePath, final ILoaderListener callback) {
        new AsyncTask<String, Void, Resources>() {
            protected void onPreExecute() {
                if (callback != null) {
                    callback.onStart();
                }
            }
            @Override
            protected Resources doInBackground(String... params) {
                try {
                    if (params.length == 1) {
                        String skinPkgPath = params[0];
                        Log.i("loadSkin", skinPkgPath);
                        File file = new File(skinPkgPath);
                        if (file == null || !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());
                        SkinConfig.saveSkinPath(context, skinPkgPath);
                        skinPath = skinPkgPath;
                        isDefaultSkin = false;
                        return skinResource;
                    }
                    return null;
                } catch (Exception e) {
                    e.printStackTrace();
                    return null;
                }
            }
            protected void onPostExecute(Resources result) {
                mResources = result;
                if (mResources != null) {
                    if (callback != null) callback.onSuccess();
                    notifySkinUpdate();
                } else {
                    isDefaultSkin = true;
                    if (callback != null) callback.onFailed();
                }
            }
        }.execute(skinPackagePath);
    }

这个方法有两个参数,第一个是皮肤包的路径,第二个就是一个简单的回调

其中doInBackground方法里面就实现了动态的去获取皮肤包的Resources,当获取成功之后,在onPostExecute方法中就将这个Resources赋值到我们定义好的变量中去,以方便我们之后的使用,注意到当获取到的这个Resources不为空时,也就是我们已经获取到了皮肤包里面的资源,我们就调用notifySkinUpdate()这个方法来通知界面去更改皮肤,如果为空就还是使用默认的皮肤。

我们来看看notifySkinUpdate()的实现

notifySkinUpdate

这里很简单,就是去遍历mSkinObservers这个集合,然后去通知更新。对于ISkinUpdate是一个接口,每个需要皮肤更新的Activity都需要去实现这个接口。

SkinManager这个类里面还有诸如getColor(int resId)、getDrawable(int resId)这样的方法,就是去获取第三方包对应的资源文件,值得注意的是如果你的第三方包里没有对应的资源文件,那么就会使用默认的资源文件,如果你有需求,你完全可以去添加一些类似getMipmap(int resID)这样的方法。

对了,还有一个比较重要的方法忘了讲

restoreDefaultTheme

这个方法就是恢复到系统的默认主题,原理和load都差不多,实现还简单了很多。SkinManager这个类就说这么多,详细实现请到源码中去查看,很多地方我都给了注释。

我们再来看看SkinInflaterFactory,在这里面主要就是做一些填充View相关的一些工作。我实现的是LayoutInflaterFactory这个接口而不是文章之前提到的LayoutInflater.Factory这个接口是因为这里需要与AppCompatActivity兼容,如果你还是用之前的那个就会出现一些错误,反正我刚弄的时候是折腾了很久的。不管怎么样原理始终是一样的。SkinInflaterFactory的作用就是去搜集那些有需要响应皮肤更改的View。

我们来看看onCreateView的实现

onCreateView

首先我们先去判断这个当前将要View是否有更改皮肤的需求,如果没有我们就返回默认的实现。如果有,我们就自己去处理

来看看createView方法的实现

createView

看起来很多,其实这个方法就是去动态的去创建View。

下面来看看parseSkinAttr的实现:

parseSkinAttr

这个方法其实就是去搜集View中换肤的时候可以更改的属性,当我们换肤的时候就是去更改的这些属性的值,这里你必须要注意一点,这个属性的值一定要是引用类型的(例如:@color/red),千万不能写死,第二个if的判断就是这个作用。到这里可能你就会有个疑问,我怎么知道哪些属性在换肤的时候需要更改。如果你细心一点肯定注意到了这行代码

SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);

这里有个AttrFacory他的作用就是根据属性名,动态的去创建SkinAttr。在AttrFacory中定义了一些类似于这样的常量:

这就是我们换肤的时候可以更改的那些属性。SkinAttr是一个抽象类,比如background就会去创建一个BackgroundAttr,本项目所用到的属性全都在attr包中。SkinAttr是比较灵活的一个地方,如果你有哪个属性在换肤的时候需要更改,你就去实现一个对应的SkinAttr。

在parseSkinAttr这个方法的最后我们将View和SkinAttr封装成了一个SkinItem然后添加到一个集合中去,最后还需注意的是,如果当前皮肤不是默认皮肤,一定要去apply一下,这样做主要是防止换了皮肤启动一些新的页面有可能导致换肤不及时的问题。SkinInflaterFactory这个类里面还提供了动态的添加SkinItem的方法,原理都和这里差不多,我就不过多的去说了。

load包里面的这两个类讲的差不多了,这里看懂了后面的内容也就是小菜一碟了,我相信你看了这里再去看源码一定会轻松地多。

2.base包

base包结构

可以看见这个包里面肯定就是Activity、Fragment、Application的实现,作用肯定就是封装一些公用的方法和属性在里面。

下面我们一个一个来分析

  • SkinBaseApplication:

    SkinBaseApplication

可以看到这里我们对SkinManager做了一些初始化的操作。以后我们有需要皮肤更改需求的应用一定要记得一定要继承于SkinBaseApplication。

  • SkinBaseActivity

    我们来看看其onCreate方法

    SkinBaseActivity

    在这里使用了我们之前自定义的View的InflaterFactory,来替换默认的Factory。记住一定要在super.onCreate(savedInstanceState);这个方法之前调用。SkinBaseActivity里面还提供了动态添加可以响应皮肤更改需求的View的相关方法。当然需要响应换肤更改的Activity都需要继承SkinBaseActivity。详细实现请看源码。

  • SkinBaseFragment

    这个和SkinBaseActivity的思想差不多。具体实现看源码,这里我只是给大家提供这个换肤框架的思想,让大家在看源码的时候更轻松。

这个框架就介绍到这,下面我们来看看怎么去使用。

在使用的时候一定要记得要Activity要去继承于SkinBaseActivity,Fragment要继承于SkinBaseFragment,Application要继承于SkinBaseApplication。当然把这个框架做为你的项目依赖项肯定是必不可少的。为了Demo的简单,这里我只使用了下面三个颜色作为可以换肤的资源,当然如果你想要使用drawable文件也是可以办到的,前提是你一定要把这个Demo看懂。

来看一个布局文件

其中

xmlns:skin="http://schemas.android.com/android/skin"

是我们自定义的,在SkinConfig有。

我们只需在有皮肤更改需求的View中加入skin:enable="true" 就OK了。

再来看看MainActicvity的部分代码

这里就是动态的添加有皮肤更改需求的View。

上面就介绍完了在布局文件中使用方法和在代码中使用方法。

我们应该怎么去换肤呢?很简单,只需调用SkinManager的load方法就可以了,把皮肤路径传进去就可以了,我的这个Demo为了简单起见,没有做在线换肤的功能,只是在本地提供了可以更换的皮肤,看到这里我相信你对怎样在线换肤已经有想法了。

怎样去换肤

最最后我们来看看怎么去开发皮肤包。其实这个是最简单的,皮肤包实际上就是一个基本的Android项目,里面不包含类文件,只有资源文件。这里只需注意 这里的资源文件名字一定要和原项目中的相同,并且只用包含那些在皮肤更改时需要改变的那些就行了!例如我的这个Demo就只是简单对上面的三种颜色做了简单的切换。开发了棕色和黑色两款皮肤,所以资源文件中只有三个color的值,开发完成之后我们需要将其打包成apk文件,为防止用户点击安装,我们将其后缀改成了skin,这样做也具有标识性。如果还是不太清楚可以直接去源码中查看。

这下再来看一看文章开头效果图是不是突然变得有思路了,快动起你的小手指去敲一个主题换肤的框架吧~~~

Demo最终效果图

好了,本文到此结束。很感谢你的耐心看完!

源码传送:MaterialDesignDemo 欢迎大家Star和Fork,bug肯定是在所难免的,有问题多多讨论。

参考链接:

  1. LayoutInflater Factories(需梯子)
  2. How to Get the Right LayoutInflater
  3. Android apk动态加载机制的研究
  4. Android换肤技术总结
时间: 2024-11-08 19:00:48

Android App切换主题的实现原理剖析的相关文章

Android 实现切换主题皮肤功能(类似于众多app中的 夜间模式,主题包等)

首先来个最简单的一键切换主题功能,就做个白天和晚上的主题好了. 先看我们的styles文件: 1 <resources> 2 3 <!-- Base application theme. --> 4 <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> 5 <!-- Customize your theme here. --> 6 &l

Android自动化测试框架新书:&lt;&lt;MonnkeyRunner实现原理剖析&gt;&gt;交流

大家觉得编写一本描述MonkeyRunner原理分析的书籍如何?估计大概10万字左右.内容大概分布如下: Monkey实现原理: 去描述运行在目标安卓机器的monkey是如何运行并处理MonkeyRunner发送过来的事件请求并把事件注入到系统的 Monkey命令处理源码情景分析:去分析关键命令事件如touch,tap等的实现原理 AndroidDebugMonitor(adb)运行原理: 分析MonkeyRunner是如何使用ddmlib库的AndroidDebugMonitor来跟目标安卓设

Android 学习笔记之切换主题

首先要有主题颜色 theme_color.xml 1 <?xml version="1.0" encoding="utf-8"?> 2 <resources> 3 4 <!--红--> 5 <color name="red">#FF6347</color> 6 <color name="dark_red">#F4511E</color> 7 &

揭秘Android App的工作原理-乐居猫学Android开发

Android App的工作原理 Android系统是基于liunx内核的,但是与传统的基于liunx的pc系统不同,用户对Android app没有绝对的掌控权.pc系统中,在应用程序的系统菜单上选择"退出"或者"关闭"之类的选项会直接杀死进程.在Android系统中不是这样的.而是由系统,当系统需要释放内存来运行新进程或者保证某些后台进程和前端进程顺利执行的时候才会释放相应应用程序的资源,这个释放过程有一个重要性的层次,接下来就听乐居猫做一下说明: androi

Android BroadcastReceiver原理剖析

这里主要跟一下android源码,看看BroadcastReceiver的工作原理.BroadcastReceiver分动态注册和静态注册,静态注册涉及到系统开机时的程序安装过程,这里关于静态注册BroadcastReceiver的过程暂时不理,等写到程序安装会有相应的解说. 我们将从普通的Activity.registerReceiver开始: //android.app.ContextWrapper.java 464 @Override 465 public Intent registerR

[android] 练习样式主题自定义activity切换动画

主要练习了自定义样式和主题,继承android系统默认的样式并修改,练习xml定义淡入淡出动画 anim/fade_in.xml <?xml version="1.0" encoding="utf-8"?> <alpha xmlns:android="http://schemas.android.com/apk/res/android" android:duration="3000" android:from

Android APP 简单高效的禁用横竖屏切换

默认情况下,Android APP的界面会随着手机方向的改变而改变,当手机处于竖屏状态,APP的界面也处于竖屏状态,而当手机处于横屏状态,APP也会自动切换到横屏状态.一般情况下APP的界面都是为竖屏设计的,一旦自动切换到横屏,界面可能就无法直视了.而且每次屏幕方向切换,当前的页面都会销毁并重新创建. 下面先做一个简单的演示 布局文件: <?xml version="1.0" encoding="utf-8"?> <LinearLayout xml

Android app应用多语言切换功能实现

最近在做一个多语言切换的功能,类似于微信的语言切换,搜了下资料基本上都是以下这种: 1. 实现的效果 和微信类似,在设置界面打开切换语言的界面,选择语言后重启 HomeActivity,语言切换完成,下次重新打开 App ,也是用户设置的语言. 2. 实现步骤 1. 添加多语言文件 在不同的 value 文件夹下(例如 value .value-en.values-zh-rTW 文件夹)添加不同语言的 string.xml 文件,我们的项目添加了英文.简体中文.繁体中文三种语言,如下图所示: 其

〖Android〗Android App项目资源字符串检查(检查是否缺少对应的翻译,导致系统切换语言后崩溃)

Android项目开发过程中,容易出现缺少对应中英文翻译的情况,这个Python脚本是用于检查字符串是否缺少了对应的翻译 1 #!/usr/bin/env python 2 # encoding: utf-8 3 4 import os, sys, getopt 5 import xml.dom.minidom 6 import subprocess 7 from xml.dom.minidom import Node 8 9 # 判断是否是App项目依据 10 Axml='AndroidMan