本文主要讲述如何在项目中,在不重启应用的情况下,实现动态换肤的效果。换肤这块做的比较好的,有网易云音乐,qq等,给用户带来了多样的界面选择和个性化定制。之前看到换肤的效果后对这块也比较好奇,就抽时间研究了下,今天给大家分享解析原理和实践中遇到的问题。
换肤的一般实现思路:
资源打包静态替换方案: 指定资源路径地址,在打包时将对应资源打包进去 build.gradle中进行对应配置
sourceSets {
// 测试版本和线上版本用同一套资源
YymTest {
res.srcDirs = ["src/Yym/res", "src/YymTest/res"]
assets.srcDirs = ["src/Yym/assets"]
}
}
这种方式是在打包时,通过指定资源文件的路径在编译打包时将对应的资源打包进去,以实现不同的主题样式等换肤需求。适合发布马甲版本的app需求。
动态换肤方案: 应用运行时,选择皮肤后,在主app中拿到对应皮肤包的Resource,将皮肤包中的 资源动态加载到应用中展示并呈现给用户。
动态换肤的一般步骤为:
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());
其中需要传入的参数即为皮肤包的文件路径地址,还有当前app的context 其中superResource为当前app的Resource对象,而skinResource即为加载后的皮肤包的Resource对象。 皮肤包的资源即可通过skinResource.getIdentifier(resName, "color", skinPackageName);这种方式拿到了。
如何找到需要换肤的View
1)通过xml标记的View: 这种方式主要要通过实现LayoutInflate.Factory2这个接口(为支持AppcompotActivty 用LayoutInflaterFactory API是一样的)。
/**
* Used with {@code LayoutInflaterCompat.setFactory()}. Offers the same API as
* {@code LayoutInflater.Factory2}.
*/
public interface LayoutInflaterFactory {
/**
* 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.
*/
public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
}
LayoutInflater 提供了setFactory(LayoutInflater.Factory factory)和setFactory2(LayoutInflater.Factory2 factory)两个方法可以让你去自定义布局的填充(有点类似于过滤器,我们在填充这个View之前可以做一些额外的事),Factory2 是在API 11才添加的。 通过实现这两个接口可以实现View的重写。Activity本身就默认实现了Factory接口,所以我们复写了Factory的onCreateView之后,就可以不通过系统层而是自己截获从xml映射的View进行相关View创建的操作,包括对View的属性进行设置(比如背景色,字体大小,颜色等)以实现换肤的效果。如果onCreateView返回null的话,会将创建View的操作交给Activity默认实现的Factory的onCreateView处理。
public class SkinInflaterFactory implements LayoutInflaterFactory {
private static final boolean DEBUG = true;
/**
* Store the view item that need skin changing in the activity
*/
private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
// if this is NOT enable to be skined , simplly skip it
boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
Log.d("ansen", "isSkinEnable----->" + isSkinEnable);
Log.d("ansen", "name----->" + name);
if (!isSkinEnable) {
return null;
}
View view = createView(context, name, attrs);
if (view == null) {
return null;
}
parseSkinAttr(context, attrs, view);
return view;
}
/**
* Invoke low-level function for instantiating a view by name. This attempts to
* instantiate a view class of the given <var>name</var> found in this
* LayoutInflater's ClassLoader.
*
* @param context
* @param name The full name of the class to be instantiated.
* @param attrs The XML attributes supplied for this instance.
* @return View The newly instantiated view, or null.
*/
private View createView(Context context, String name, AttributeSet attrs) {
View view = null;
try {
if (-1 == name.indexOf('.')) {
view = createViewFromPrefix(context, name, "android.view.", attrs);
if (view == null) {
view=createViewFromPrefix(context, name, "android.widget.", attrs);
if(view==null){
view= createViewFromPrefix(context, name, "android.webkit.", attrs);
}
}
} else {
L.i("自定义View to create " + name);
view=createViewFromPrefix(context, name, null, attrs);
}
} catch (Exception e) {
L.e("error while create 【" + name + "】 : " + e.getMessage());
view = null;
}
return view;
}
private View createViewFromPrefix(Context context, String name, String prefix, AttributeSet attrs) {
View view;
try {
view = createView(context, name, prefix, attrs);
} catch (Exception e) {
view = null;
}
return view;
}
public void applySkin() {
if (ListUtils.isEmpty(mSkinItems)) {
return;
}
for (SkinItem si : mSkinItems) {
if (si.view == null) {
continue;
}
si.apply();
}
}
public void addSkinView(SkinItem item) {
mSkinItems.add(item);
}
}
对View属性进行识别并转化为皮肤属性实体
/**
* Collect skin able tag such as background , textColor and so on
*
* @param context
* @param attrs
* @param view
*/
private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
if (!AttrFactory.isSupportedAttr(attrName)) {
continue;
}
if (attrValue.startsWith("@")) {
try {
int id = Integer.parseInt(attrValue.substring(1));
String entryName = context.getResources().getResourceEntryName(id);
String typeName = context.getResources().getResourceTypeName(id);
SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
if (mSkinAttr != null) {
viewAttrs.add(mSkinAttr);
}
} catch (NumberFormatException e) {
e.printStackTrace();
} catch (NotFoundException e) {
e.printStackTrace();
}
}
}
if (!ListUtils.isEmpty(viewAttrs)) {
SkinItem skinItem = new SkinItem();
skinItem.view = view;
skinItem.attrs = viewAttrs;
mSkinItems.add(skinItem);
if (SkinManager.getInstance().isExternalSkin()) {
skinItem.apply();
}
}
下面通过skin:enbale="true"这种方式,对布局中需要换肤的View进行标记
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:skin="http://schemas.android.com/android/skin"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/hall_back_color"
skin:enable="true"
>
<code.solution.widget.CustomActivityBar
android:id="@+id/custom_activity_bar"
android:layout_width="match_parent"
android:layout_height="@dimen/widget_action_bar_height"
app:common_activity_title="@string/app_name"
app:common_activity_title_gravity="center"
app:common_activity_title_icon="@drawable/ic_win_cp"
/>
</LinearLayout>
在SKinInflaterFactory的onCreateView 方法中,实际是对xml中映射的每个View 进行过滤。如果skin:enbale不为true则直接返回null交给系统默认去创建。而如果为true,则自己去创建这个View,并将这个VIew的所有属性比如id, width height,textColor,background等与支持换肤的属性进行对比。比如我们支持换background textColor listSelector等, android:background="@color/hall_back_color" 这个属性,在进行换肤的时候,如果皮肤包里存在hall_back_color这个值的设置,就将这个颜色值替换为皮肤包里的颜色值,以完成换肤的需求。同时,也会将这个需要换肤的View保存起来。
如果在切换换肤之后,进入一个新的页面,就在进入这个页面Activity的 InlfaterFacory的onCreateView里根据skin:enable="true" 这个标记,进行判断。为true则进行换肤操作。而对于切换换肤操作时,已经存在的页面,就对这几个存在页面保存好的需要换肤的View进行换肤操作。
2)在代码中动态添加的View
上述是针对在布局中设置skin:ebable="true"的View进行换肤,那么如果我们的View不是通过布局文件,而是通过在代码种创建的View,怎样换肤呢?
public void dynamicAddSkinEnableView(Context context, View view, List<DynamicAttr> pDAttrs) {
List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();
SkinItem skinItem = new SkinItem();
skinItem.view = view;
for (DynamicAttr dAttr : pDAttrs) {
int id = dAttr.refResId;
String entryName = context.getResources().getResourceEntryName(id);
String typeName = context.getResources().getResourceTypeName(id);
SkinAttr mSkinAttr = AttrFactory.get(dAttr.attrName, id, entryName, typeName);
viewAttrs.add(mSkinAttr);
}
skinItem.attrs = viewAttrs;
skinItem.apply();
addSkinView(skinItem);
}
public void dynamicAddSkinEnableView(Context context, View view, String attrName, int attrValueResId) {
int id = attrValueResId;
String entryName = context.getResources().getResourceEntryName(id);
String typeName = context.getResources().getResourceTypeName(id);
SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
SkinItem skinItem = new SkinItem();
skinItem.view = view;
List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();
viewAttrs.add(mSkinAttr);
skinItem.attrs = viewAttrs;
skinItem.apply();
addSkinView(skinItem);
}
即在Activity中通过比如 dynamicAddSkinEnableView(context, mTextView,"textColor",R.color.main_text_color)即可完成对动态创建的View的换肤操作。
本文研究是基于github开源项目Android-Skin-Loader进行的。这个框架主要是动态加载皮肤包,在不需要重启应用的前提下,实现对页面布局等动态换肤的操作。皮肤包独立制作和维护,不和主工程产生耦合。同时由后台服务器下发,可即时在线更新不依赖客户端版本。
本文来自网易实践者社区,经作者朱强龙授权发布。