1、背景
目标用户群体多元化、审美观参差不齐。默认界面效果并不能满足所有用户,提供换肤功能满足用户个性化需求。
2、技术实现
换肤就是改变Viwe样式,例如改变字体颜色、View背景等达到不同显示效果。
2.1 怎么从皮肤包加载资源?
Android资源是通过AssetManager 查找资源名称加载,大部分会用getResources().getXX(int resId)方法得到一个资源,实际上Resources通过ID查找到资源名称,再交给AssetManager加载,以下resources.arsc是资源映射表,Id name都有对应关系。
resources.arsc资源映射表
备注:皮肤包是一个只包含资源的APK。
AssetManager.java
/**
* Add an additional set of assets to the asset manager. This can be
* either a directory or ZIP file. Not for use by applications. Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {
return addAssetPathInternal(path, false);
}
复制代码
既然是AssetManager加载资源,那么从它入手找答案,AssetManager类中的 addAssetPath方法可以将一个ZIP压缩包添加到AssetManager,并加载这个ZIP资源。{@hide} 代表是隐藏API,不过可以通过反射调用。
反射调用addAssetPath 得到ZIP包的Resources。
public Resources loadResource() {
try {
Class<AssetManager> amClz = AssetManager.class;
AssetManager assetManager = amClz.newInstance();
Method method = amClz.getDeclaredMethod("addAssetPath", String.class);
method.invoke(assetManager, "/mnt/shared/Other/app-debug.skin");
return new Resources(assetManager, this.getResources().getDisplayMetrics(), this.getResources().getConfiguration());
} catch (Exception e) {
}
return null;
}
访问资源
int resID = loadResource().getIdentifier("app_name", "string", "com.gonghuiyuan.skinlib");
Log.i("vip", loadResource().getString(resID));
复制代码
2.2. 资源拿到后设置到View?
现在知道怎么从皮肤包加载资源,拿到之后又有新的问题,怎么知道哪些View需要换肤?这个倒好解决,给Veiw做标记,获取View属性skinEnable==true说明View需要换肤。接下来递归ViewGroup.getChildAt(),拿到所有带标记的View,逐一设置TextColor,background等属性。但是这样效率低。假设LayoutInflater 加载XML到一个Activity这个过程中,这个时候将带标记的View保存起来,就不用遍历。
标记
<TextView
skin:skinEnable"true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!" />
复制代码
LayoutInflater有个LayoutInflater.Factory接口,意思是渲染View的时候可以Hook,可以自定义标签名称,但不要用系统名称。
LayoutInflater.Factory.java
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.
*
* <p>
* 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);
}
复制代码
举个栗子:
MainActivity.java代码
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
LayoutInflaterCompat.setFactory(LayoutInflater.from(this), new LayoutInflaterFactory() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
Log.e("vip", "name = " + name);
int n = attrs.getAttributeCount();
for (int i = 0; i < n; i++) {
Log.e("vip", attrs.getAttributeName(i) + " , " + attrs.getAttributeValue(i));
}
AppCompatDelegate delegate = getDelegate();
View view = delegate.createView(parent, name, context, attrs);
return view;
}
});
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
}
布局文件activity_main
<LinearLayout xmlns: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:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="tv1" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="tv2" />
</LinearLayout>
复制代码
说明 | 白色框框部分,就是那两个Textview信息,这个例子说明每创建一个View都会走onCreateView方法。Hook的意思是说不用修改XML文件,就可以将Textview改为ImageView等其它控件。 | |
效果图 | ||
Hook |
3、动手撸个Demo
直接贴出代码,用到的都是上面两个例子。注意: 皮肤包的资源名称要求跟宿主的一致。这里就不出效果图 只是改个背景色。当然只是个Demo,如果要接入到正式项目,还是有很多问题要处理的。
public class MainActivity extends AppCompatActivity {
private List<AttrsBean> attrsLists = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
//记录要换肤的View及属性
LayoutInflaterCompat.setFactory(LayoutInflater.from(this), new LayoutInflaterFactory() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
AppCompatDelegate delegate = getDelegate();
View view = delegate.createView(parent, name, context, attrs);
//指定命名空间的属性
boolean skinEnable = attrs.getAttributeBooleanValue("http://schemas.android.com/apk/skin", "skinEnable", false);
if (skinEnable) {//找到需要换肤的View,假设我们只支持Background
int n = attrs.getAttributeCount();
if(view==null){
try {
view = LayoutInflater.from(context).createView(name, null, attrs);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
for (int i = 0; i < n; i++) {
String attrName = attrs.getAttributeName(i);//属性名
String attrValue = attrs.getAttributeValue(i);//属性值
if ("background".equals(attrName)) {
String resName = context.getResources().getResourceEntryName(Integer.parseInt(attrValue.replace("@", "")));
AttrsBean attrsBean = new AttrsBean(view, new AttrsBean.BackgroudAttr(), resName);
attrsLists.add(attrsBean);
}
}
}
return view;
}
});
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
AttrsBean.application = getApplication();
findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
String path = "/mnt/shared/Other/skin_a.skin";
String packageName = "com.gonghuiyuan.skinlib";
AttrsBean.loadResource(MainActivity.this, path, packageName);
for (AttrsBean attr : attrsLists) {
attr.getSkinAttr().apply(attr);
}
}
});
}
}
复制代码
public class AttrsBean {
private View veiw;
private SkinAttr skinAttr;
private String attrName;
public View getVeiw() {
return veiw;
}
public void setVeiw(View veiw) {
this.veiw = veiw;
}
public SkinAttr getSkinAttr() {
return skinAttr;
}
public void setSkinAttr(SkinAttr skinAttr) {
this.skinAttr = skinAttr;
}
public String getAttrName() {
return attrName;
}
public void setAttrName(String attrName) {
this.attrName = attrName;
}
public AttrsBean(View veiw, SkinAttr skinAttr, String attrName) {
this.veiw = veiw;
this.skinAttr = skinAttr;
this.attrName = attrName;
}
public static abstract class SkinAttr {
abstract void apply(AttrsBean attrsBean);
}
public static class TextColorAttr extends SkinAttr {
@Override
void apply(AttrsBean attrsBean) {
if (attrsBean.getVeiw() instanceof TextView) {
TextView tv = (TextView) attrsBean.getVeiw();
tv.setTextColor(getColor(attrsBean.getAttrName()));
}
}
}
public static class BackgroudAttr extends SkinAttr {
@Override
void apply(AttrsBean attrsBean) {
attrsBean.getVeiw().setBackgroundColor(getColor(attrsBean.getAttrName()));
}
}
private static Map<String, Resources> resourcesMap = new HashMap<>();
public static Application application;
public static Resources mResources;
public static String packageName;
public static int getColor(String attrName) {
return mResources.getColor(mResources.getIdentifier(attrName, "color", packageName));
}
//加载Resource
public static void loadResource(Context context, String path, String packageName) {
if (packageName == null) {
packageName = context.getPackageName();
}
AttrsBean.packageName = packageName;
if (path == null) {
mResources = context.getResources();
return;
}
if (resourcesMap.get(path) != null) {
mResources = resourcesMap.get(path);
return;
}
try {
Class<AssetManager> amClz = AssetManager.class;
AssetManager assetManager = amClz.newInstance();
Method method = amClz.getDeclaredMethod("addAssetPath", String.class);
method.invoke(assetManager, path);
Resources resources = new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
resourcesMap.put(path, resources);
mResources = resources;
} catch (Exception e) {
Log.e("vip", e.toString());
}
}
}
复制代码
Layout文件
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:skin="http://schemas.android.com/apk/skin"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorAccent"
android:orientation="vertical"
skin:skinEnable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="tv1"
android:textColor="@color/colorPrimary"
skin:skinEnable="true" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="tv2"
android:textColor="@color/colorPrimary"
skin:skinEnable="true" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="更换" />
</LinearLayout>
复制代码
4、最后说点
其它换肤方案: 手Q黑科技:http://blog.youkuaiyun.com/cc_want/article/details/52510624 。 上面例子看完,建议亲自看Android-Skin-Loader源码,我也是从这个框架学习的。
参考资料
- 罗升阳>Android应用程序资源的编译和打包过程分析 http://blog.youkuaiyun.com/luoshengyang/article/details/8744683
- 任玉刚> Android源码分析-资源加载机制 http://blog.youkuaiyun.com/singwhatiwanna/article/details/24532419
- 承香墨影> 听说 Android 9.0 要禁用 @Hide Api 的调用,你怎么看? https://www.cnblogs.com/plokmju/p/8334869.html
- Android-Skin-Loader github https://github.com/fengjundev/Android-Skin-Loader
- Square开源交流 QQ群:166354503