qq 网易云音乐的换肤功能很炫酷,这里总结下换肤原理。
换肤分为两种模式,静态换肤 动态换肤。静态换肤就是把不同皮肤的资源打包到apk中,使用时切换,
这种换肤的弊端就不再多说了(种类固定,apk大)。这里介绍下动态换肤,主要步骤如下:
1,注册监听所有Activity的生命周期
2,监听所有view的创建,找到可以换的属性并存储
3,加载皮肤包(其实是一个只包含资源的apk)
4,执行换肤操作
下面是实现技术细节:
1,注册监听所有Activity的生命周期
application.registerActivityLifecycleCallbacks(new SkinActivityLifecycle());
注册所有activity的生命周期,跟BaseActivity比的优势是完全非侵入式,我们自己做activity管理也可用此方法。
2,监听所有view的创建,找到可以换的属性并存储
监听到activty的创建以后就可以设置LayoutInflater.Factory2进行监听所有View的创建过程,
public void onActivityCreated(Activity activity, Bundle savedInstanceState) { LayoutInflater layoutInflater = LayoutInflater.from(activity); //获得Activity的布局加载器 try { //Android 布局加载器 使用 mFactorySet 标记是否设置过Factory //如设置过抛出一次 //设置 mFactorySet 标签为false Field field = LayoutInflater.class.getDeclaredField("mFactorySet"); field.setAccessible(true); field.setBoolean(layoutInflater, false); } catch (Exception e) { e.printStackTrace(); } SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory(); LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutFactory); //注册观察者 SkinManager.getInstance().addObserver(skinLayoutFactory); mLayoutFactoryMap.put(activity, skinLayoutFactory); }
在SkinLayoutFactory中能监听到此Activity 所有View的创建过程,
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) { //反射 classloader View view = createViewFromTag(name, context, attrs); // 自定义View if (null == view) { view = createView(name, context, attrs); } //筛选符合属性的View skinAttribute.load(view, attrs); return view; }
此时需要 把可以换肤的属性筛选出来
public void load(View view, AttributeSet attrs) { List<SkinPair> skinPairs = new ArrayList<>(); for (int i = 0; i < attrs.getAttributeCount(); i++) { //获得属性名 String attributeName = attrs.getAttributeName(i); //是否符合 需要筛选的属性名 if (mAttributes.contains(attributeName)) { String attributeValue = attrs.getAttributeValue(i); //写死了 不管了 if (attributeValue.startsWith("#")) { continue; } //资源id int resId; if (attributeValue.startsWith("?")) { //attr Id int attrId = Integer.parseInt(attributeValue.substring(1)); //获得 主题 style 中的 对应 attr 的资源id值 resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0]; } else { // @12343455332 resId = Integer.parseInt(attributeValue.substring(1)); } if (resId != 0) { //可以被替换的属性 SkinPair skinPair = new SkinPair(attributeName, resId); skinPairs.add(skinPair); } } } //将View与之对应的可以动态替换的属性集合 放入 集合中 if (!skinPairs.isEmpty()) { SkinView skinView = new SkinView(view, skinPairs); skinView.applySkin(); mSkinViews.add(skinView); } }
3,加载皮肤包(其实是一个只包含资源的apk)
先来了解下资源的加载 原理
如果是assert目录下的资源给AssertManager资源名字即可找到,如果是资源ID需要通过resources.arsc映射表找到对应资源名字再给到AssetManager.
我们要做的就是给资源资源apk创建一个Resources用于查找资源,怎么做呢:
public void loadSkin(String path) { //还原默认皮肤包 if (TextUtils.isEmpty(path)) { SkinPreference.getInstance().setSkin(""); SkinResources.getInstance().reset(); } else { try { AssetManager assetManager = AssetManager.class.newInstance(); // 添加资源进入资源管理器 Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String .class); addAssetPath.setAccessible(true); addAssetPath.invoke(assetManager, path); Resources resources = application.getResources(); // 横竖、语言 Resources skinResource = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration()); //获取外部Apk(皮肤包) 包名 PackageManager mPm = application.getPackageManager(); PackageInfo info = mPm.getPackageArchiveInfo(path, PackageManager .GET_ACTIVITIES); String packageName = info.packageName; SkinResources.getInstance().applySkin(skinResource, packageName); //保存当前使用的皮肤包 SkinPreference.getInstance().setSkin(path); } catch (Exception e) { e.printStackTrace(); } } //应用皮肤包 setChanged(); //通知观察者 notifyObservers(); }
需要通过AssertManager用反射把资源apk路径设置好,然后传给Resources,就可以使用里边的资源了,
/** * 根据资源Id获取到在资源apk中的id * @param resId * @return */ public int getIdentifier(int resId) { if (isDefaultSkin) { return resId; } //在皮肤包中不一定就是 当前程序的 id //获取对应id 在当前的名称 colorPrimary //R.drawable.ic_launcher String resName = mAppResources.getResourceEntryName(resId);//ic_launcher String resType = mAppResources.getResourceTypeName(resId);//drawable int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName); return skinId; }
/** * 获取资源apk中图片资源 * @param resId * @return */ public Drawable getDrawable(int resId) { //如果有皮肤 isDefaultSkin false 没有就是true if (isDefaultSkin) { return mAppResources.getDrawable(resId); } int skinId = getIdentifier(resId); if (skinId == 0) { return mAppResources.getDrawable(resId); } return mSkinResources.getDrawable(skinId); }
4,执行换肤操作
找到了要换肤的View和属性,加载了资源Apk接下来就是换肤了:、
public void applySkin() { for (SkinPair skinPair : skinPairs) { Drawable left = null, top = null, right = null, bottom = null; switch (skinPair.attributeName) { case "background": Object background = SkinResources.getInstance().getBackground(skinPair .resId); //Color if (background instanceof Integer) { view.setBackgroundColor((Integer) background); } else { ViewCompat.setBackground(view, (Drawable) background); } break; case "src": background = SkinResources.getInstance().getBackground(skinPair .resId); if (background instanceof Integer) { ((ImageView) view).setImageDrawable(new ColorDrawable((Integer) background)); } else { ((ImageView) view).setImageDrawable((Drawable) background); } break; case "textColor": ((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList (skinPair.resId)); break; case "drawableLeft": left = SkinResources.getInstance().getDrawable(skinPair.resId); break; case "drawableTop": top = SkinResources.getInstance().getDrawable(skinPair.resId); break; case "drawableRight": right = SkinResources.getInstance().getDrawable(skinPair.resId); break; case "drawableBottom": bottom = SkinResources.getInstance().getDrawable(skinPair.resId); break; default: break; } if (null != left || null != right || null != top || null != bottom) { ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom); } } }给View的每个可以换肤的属性设置数据