您的位置:首页 > 移动开发 > Android开发

【Android】换肤技术讲解

2018-03-01 22:56 162 查看
主题,是许多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."
};  //这些都是系统组件
@Override
public 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即可

//创建一个view
private 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);
}

@Override
public 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);
}

@Override
public 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的构造函数

@Deprecated
public 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 ;
@Override
protected 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 ";

@Override
protected 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,将宿主项目打包到手机,即可

至此,换肤技术就讲解完毕

源码下载
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: