Android 内置的换肤
Android 中可以动态切换主题,但是只能切换主题的颜色,并且主题的切换会对view进行重新加载并不连贯.
Android 中内置的换肤需要在apk中存在需要换肤的图片等资源,导致apk包的增大,并且不能进行更多皮肤的扩展,需要重新打包上线.
如何实现动态换肤
类似网易云音乐的动态换肤,是通过下载皮肤包,加载皮肤包来实现动态换肤的功能,那么皮肤包是啥呢? 其实就是apk包,读取apk中的资源文件进行替换就可以实现动态的换肤功能.
实现代码地址:https://github.com/JakePrim/PrimSkinCore
那么动态换肤的实现原理是什么? 我们知道在Activity和Fragment通过 setContentView 来加载布局文件,setContentView
中如何加载布局呢? 内部通过 LayoutInflater 布局加载器来加载布局. LayoutInflater
如何加载布局呢? 我们看如下系统的源代码:
LayoutInflater.from(mContext).inflate(resId, contentParent);//inflate 做了什么呢? 点击看下inflate 方法做了什么public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {.....//这里是获取返回的viewfinal View temp = createViewFromTag(root, name, inflaterContext, attrs);........}//在createViewFromTag返回了一个viewView createViewFromTag(View parent, String name, Context context, AttributeSet attrs,boolean ignoreThemeAttr) {.....View view;if (mFactory2 != null) {//如果mFactory2 != null 则通过mFactory2加载view = mFactory2.onCreateView(parent, name, context, attrs);} else if (mFactory != null) {//如果mFactory != null 则通过mFactory加载view = mFactory.onCreateView(name, context, attrs);} else {view = null;}if (view == null && mPrivateFactory != null) {view = mPrivateFactory.onCreateView(parent, name, context, attrs);}//默认通过一下方式返回viewif (view == null) {final Object lastContext = mConstructorArgs[0];mConstructorArgs[0] = context;try {if (-1 == name.indexOf('.')) {view = onCreateView(parent, name, attrs);} else {view = createView(name, null, attrs);}} finally {mConstructorArgs[0] = lastContext;}}return view;......}
上述代码中首先判断mFactory2是否为空,如果不为空就交给了mFactory2去处理返回view,很明显google给了我们支持自定义的方法.setFactory2方法还是public的公有的方法,我们只需要实现Factory2,对view进行响应的处理然后返回就可以实现动态换肤. 在上述代码中还有一个 mFactory 其实是和mFactory2 一样的道理,实现mFactory也是一样的只不过 mFactory2处于第一个,防止有其他的地方实现了mFactory2,所以我们就需要去实现mFactory2,确保mFactory2被正确运行.
/*** Like {@link #setFactory}, but allows you to set a {@link Factory2}* interface.*/public void setFactory2(Factory2 factory) {if (mFactorySet) {throw new IllegalStateException("A factory has already been set on this LayoutInflater");}if (factory == null) {throw new NullPointerException("Given factory can not be null");}mFactorySet = true;if (mFactory == null) {mFactory = mFactory2 = factory;} else {mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);}}
我们看了上述的系统源代码,知道了系统是支持自定义mFactory2,来自己实现对view对加载,返回view.加载view只需要参考系统的代码即可,核心的代码就是筛选属性,然后修改属性的值,来实现颜色和图片的改变.
那么系统是如何加载view的呢? 我们从系统的源代码中来查找,在 createViewFromTag 方法中name.indexOf(‘.’)如果布局中的view带. 这说明是自定义view,否则是基本的view,我们先来看一下基本的view,来看一下 onCreateView 方法
if (view == null) {final Object lastContext = mConstructorArgs[0];mConstructorArgs[0] = context;try {if (-1 == name.indexOf('.')) {view = onCreateView(parent, name, attrs);} else {view = createView(name, null, attrs);}} finally {mConstructorArgs[0] = lastContext;}}
onCreateView其实是有AsyncLayoutInlater调用的onCreateView
private static class BasicInflater extends LayoutInflater {private static final String[] sClassPrefixList = {"android.widget.","android.webkit.","android.app."};BasicInflater(Context context) {super(context);}@Overridepublic LayoutInflater cloneInContext(Context newContext) {return new BasicInflater(newContext);}@Overrideprotected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {for (String prefix : sClassPrefixList) {try {View view = createView(name, prefix, attrs);if (view != null) {return view;}} catch (ClassNotFoundException e) {// In this case we want to let the base class take a crack// at it.}}return super.onCreateView(name, attrs);}}
最核心的代码如下: 本质上是通过反射来实现返回View对象即可.是不是很简单,系统的源码就是这样加载布局的.
if (constructor == null) {// Class not found in the cache, see if it's real, and try to add itclazz = mContext.getClassLoader().loadClass(prefix != null ? (prefix + name) : name).asSubclass(View.class);if (mFilter != null && clazz != null) {boolean allowed = mFilter.onLoadClass(clazz);if (!allowed) {failNotAllowed(name, prefix, attrs);}}constructor = clazz.getConstructor(mConstructorSignature);constructor.setAccessible(true);sConstructorMap.put(name, constructor);}
我们在得到view对象之后,获取view的属性将view的属性资源修改为皮肤包的资源.需要采集需要换肤的需要替换的属性.
采集如下基本属性,替换为皮肤包中的资源ID
static {mAttributes.add("background");mAttributes.add("src");mAttributes.add("textColor");mAttributes.add("drawableLeft");mAttributes.add("drawableTop");mAttributes.add("drawableRight");mAttributes.add("drawableBottom");}
动态换肤的实现
在上述中我们知道通过布局加载器LayoutInflater来加载布局,所以我们需要每进入一个Activity拿到 LayoutInflater 设置自定义布局加载工厂,如果建立一个BaseActivity对代码对入侵性很强,Application中提供监听App的所有Activity的生命周期回调.
代码如下
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {private HashMap<Activity, SkinLayoutFactory> mLayoutFactoryMap = new HashMap<>();@Overridepublic void onActivityCreated(Activity activity, Bundle savedInstanceState) {//拿到每个activity的布局加载器LayoutInflater layoutInflater = LayoutInflater.from(activity);//mFactorySet 如果为true 抛出异常,有可能会出现这种情况,通过反射将mFactorySet设置为falsetry {Field field = LayoutInflater.class.getDeclaredField("mFactorySet");field.setAccessible(true);field.setBoolean(layoutInflater, false);} catch (Exception e) {e.printStackTrace();}//自定义布局处理工厂SkinLayoutFactory factory = new SkinLayoutFactory();//设置工厂LayoutInflaterCompat.setFactory2(layoutInflater, factory);}@Overridepublic void onActivityStarted(Activity activity) {}@Overridepublic void onActivityResumed(Activity activity) {}@Overridepublic void onActivityPaused(Activity activity) {}@Overridepublic void onActivityStopped(Activity activity) {}@Overridepublic void onActivitySaveInstanceState(Activity activity, Bundle outState) {}@Overridepublic void onActivityDestroyed(Activity activity) {}}
接下来就是自定义 LayoutInflater.Factory2 ,Factory2的实现其实就是参考系统的实现,代码原理也非常简单,代码如下
public class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {private static final String[] sClassPrefixList = {"android.widget.","android.webkit.","android.app."};private static final HashMap<String, Constructor<? extends View>> sConstructorMap =new HashMap<String, Constructor<? extends View>>();private static final Class<?>[] mConstructorSignature = new Class[]{Context.class, AttributeSet.class};private SkinAttribute skinAttribute;public SkinLayoutFactory() {skinAttribute = new SkinAttribute();}@Overridepublic View onCreateView(View parent, String name, Context context, AttributeSet attrs) {//参考系统的实现View view = createViewFromTag(context, name, attrs);if (view == null) {view = createView(name, context, attrs);}//采集view的属性skinAttribute.load(view,attrs);return view;}@Overridepublic View onCreateView(String name, Context context, AttributeSet attrs) {return null;}private View createViewFromTag(Context context, String name, AttributeSet attrs) {if (name.contains(".")) {//如果布局xml中的view 名包含.表示为自定义view 此处先不做处理return null;}View view = null;for (String prefix : sClassPrefixList) {//遍历view属于哪个包中 然后反射创建view对象try {view = createView(prefix + name, context, attrs);if (view != null) {break;}} catch (Exception e) {// In this case we want to let the base class take a crack// at it.}}return view;}private View createView(String name, Context context, AttributeSet attrs) {Constructor<? extends View> constructor = sConstructorMap.get(name);try {if (constructor == null) {Class<? extends View> clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);constructor = clazz.getConstructor(mConstructorSignature);constructor.setAccessible(true);sConstructorMap.put(name, constructor);}} catch (Exception e) {}if (null != constructor) {try {return constructor.newInstance(context, attrs);} catch (Exception e) {}}return null;}@Overridepublic void update(Observable o, Object arg) {skinAttribute.applySkin();}}
真正实现换肤的就是,对Attribute的操作, 接下来看一下SkinAttribute的实现很非常简单,其实就是遍历所有view的属性拿到自己需要的属性,然后存储在集合中如下:
//采集的属性集合static {mAttributes.add("background");mAttributes.add("src");mAttributes.add("textColor");mAttributes.add("drawableLeft");mAttributes.add("drawableTop");mAttributes.add("drawableRight");mAttributes.add("drawableBottom");}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("#")) {//写死的先不去管 #000000continue;}int resId;if (attributeValue.startsWith("?")) {//attrIdint attrId = Integer.parseInt(attributeValue.substring(1));resId = utils.getResId(view.getContext(), new int[]{attrId})[0];} else {//@1232311resId = Integer.parseInt(attributeValue.substring(1));}if (resId != 0) {//将匹配的都存储下来SkinPair skinPair = new SkinPair(attributeName, resId);skinPairs.add(skinPair);}}}//将布局中每个view与之对应的属性集合放入到对应的view集合中if (!skinPairs.isEmpty()) {SkinView skinView = new SkinView(view, skinPairs);//每次新加载activity就要进行换肤applySkin();skinViews.add(skinView);}}
至于SkinView类存储了属性名和资源ID和当前的view
/*** 保存每个view要修改的属性值列表*/static class SkinView {View view;List<SkinPair> skinPairs;public SkinView(View view, List<SkinPair> skinPairs) {this.view = view;this.skinPairs = skinPairs;}}static class SkinPair {String attrName;int resId;public SkinPair(String attrName, int resId) {this.attrName = attrName;this.resId = resId;}}
上述代码就是核心的实现,最后我们需要实现初始化和资源的切换,代码如下 通过皮肤包的路径,Application获取到资源管理器AssetManager获取皮肤包的资源,然后通过SkinResources来实现资源的加载,然后使SkinLayoutFactory做为观察者,当更换皮肤包时更新SkinLayoutFactory. 然后通过SkinAttribute将属性的资源更改为皮肤包的资源
/*** 加载皮肤包** @param path*/public void loadSkin(String path) {if (path == null || path.isEmpty()) {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 skinResources = new Resources(assetManager,resources.getDisplayMetrics(), resources.getConfiguration());//加载皮肤包资源//皮肤包的包名PackageManager packageManager = application.getPackageManager();//获取一个apk的包名PackageInfo info = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);String packageName = info.packageName;Log.e(TAG, "loadSkin: " + packageName);//更换为皮肤包资源SkinResources.getInstance().applySkin(skinResources, packageName);//记录使用的皮肤包SkinPreference.getInstance().setSkin(path);} catch (Exception e) {e.printStackTrace();}}//通知观察者 //应用皮肤包setChanged();notifyObservers();}
SkinResources的实现,其实就是根据app的资源的资源名和资源类型,通过皮肤包的资源返回皮肤包的资源ID,如果没有就返回当前app的资源ID.
public class SkinResources {//皮肤的资源private Resources mSkinResources;//当前应用的资源private Resources mAppResources;//皮肤包名private String mSkinName;//是否使用默认皮肤private boolean isDefalueSkin = true;public static SkinResources instance;public static void init(Context context) {if (null == instance) {synchronized (SkinResources.class) {if (null == instance) {instance = new SkinResources(context);}}}}public static SkinResources getInstance() {return instance;}public SkinResources(Context context) {mAppResources = context.getResources();}public void reset() {mSkinResources = null;mSkinName = "";isDefalueSkin = true;}public void applySkin(Resources resources, String name) {mSkinResources = resources;mSkinName = name;isDefalueSkin = false;}/*** 通过资源ID 获取皮肤包中对应的资源ID 然后进行设置** @param resId* @return*/public int getIdentfier(int resId) {if (isDefalueSkin) {return resId;}//通过当前应用资源获取到 资源名 和 资源类型,然后通过资源名 资源类型 资源ID 获取到皮肤包中的资源IDString resourceName = mAppResources.getResourceEntryName(resId);String resourceTypeName = mAppResources.getResourceTypeName(resId);//getIdentifier ?? 获取到皮肤包对应到资源IDint skinId = mSkinResources.getIdentifier(resourceName, resourceTypeName, mSkinName);return skinId;}private static final String TAG = "SkinResources";public int getColor(int resId) {if (isDefalueSkin) {return mAppResources.getColor(resId);}int skinId = getIdentfier(resId);Log.e(TAG, "getColor: " + skinId);if (skinId == 0) {return mAppResources.getColor(resId);}return mSkinResources.getColor(skinId);}public ColorStateList getColorStateList(int resId) {if (isDefalueSkin) {return mAppResources.getColorStateList(resId);}int skinId = getIdentfier(resId);if (skinId == 0) {return mAppResources.getColorStateList(resId);}return mSkinResources.getColorStateList(skinId);}public Drawable getDrawable(int resId) {//如果有皮肤 isDefaultSkin false 没有就是trueif (isDefalueSkin) {return mAppResources.getDrawable(resId);}int skinId = getIdentfier(resId);if (skinId == 0) {return mAppResources.getDrawable(resId);}return mSkinResources.getDrawable(skinId);}/*** 可能是Color 也可能是drawable** @return*/public Object getBackground(int resId) {String resourceTypeName = mAppResources.getResourceTypeName(resId);if (resourceTypeName.equals("color")) {return getColor(resId);} else {// drawablereturn getDrawable(resId);}}public String getString(int resId) {try {if (isDefalueSkin) {return mAppResources.getString(resId);}int skinId = getIdentfier(resId);if (skinId == 0) {return mAppResources.getString(skinId);}return mSkinResources.getString(skinId);} catch (Resources.NotFoundException e) {}return null;}public Typeface getTypeface(int resId) {String skinTypefacePath = getString(resId);if (TextUtils.isEmpty(skinTypefacePath)) {return Typeface.DEFAULT;}try {Typeface typeface;if (isDefalueSkin) {typeface = Typeface.createFromAsset(mAppResources.getAssets(), skinTypefacePath);return typeface;}typeface = Typeface.createFromAsset(mSkinResources.getAssets(), skinTypefacePath);return typeface;} catch (RuntimeException e) {}return Typeface.DEFAULT;}}
当切换资源通知观察者SkinLayoutFactory的SkinAttribute 来采集属性和更换资源ID.代码的实现都非常简单
/*** 更换皮肤*/public void applySkin() {//遍历保存的表然后换皮肤for (SkinView skinView : skinViews) {skinView.applySkin();}}/*** 保存每个view要修改的属性值列表*/static class SkinView {View view;List<SkinPair> skinPairs;public SkinView(View view, List<SkinPair> skinPairs) {this.view = view;this.skinPairs = skinPairs;}public void applySkin() {for (SkinPair skinPair : skinPairs) {Drawable left = null, right = null, top = null, bottom = null;switch (skinPair.attrName) {case "background":Object background = SkinResources.getInstance().getBackground(skinPair.resId);//可能是一个图片或者颜色if (background instanceof Integer) {//color 选择器view.setBackgroundColor((Integer) background);} else {ViewCompat.setBackground(view, (Drawable) background);}break;case "src":if (view instanceof ImageView) {ImageView imageView = (ImageView) view;Object background1 = SkinResources.getInstance().getBackground(skinPair.resId);if (background1 instanceof Integer) {imageView.setImageDrawable(new ColorDrawable((Integer) background1));} else {imageView.setImageDrawable((Drawable) background1);}}break;case "textColor":int color = SkinResources.getInstance().getColor(skinPair.resId);if (view instanceof TextView) {TextView textView = (TextView) view;textView.setTextColor(color);}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;}if (null != left || null != right || null != top || null != bottom) {((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,bottom);}}}}
OK,现在我们实现了基本的动态换肤功能,目前我们实现的换肤功能还没有实现自定义view和状态栏的换肤功能.下一节来讲解如何实现自定义view的换肤和状态栏的换肤,其实代码写到这里大家都知道了实现的原理可以先试着自己实现.
