Android 内置的换肤

Android 中可以动态切换主题,但是只能切换主题的颜色,并且主题的切换会对view进行重新加载并不连贯.
Android 中内置的换肤需要在apk中存在需要换肤的图片等资源,导致apk包的增大,并且不能进行更多皮肤的扩展,需要重新打包上线.

如何实现动态换肤

类似网易云音乐的动态换肤,是通过下载皮肤包,加载皮肤包来实现动态换肤的功能,那么皮肤包是啥呢? 其实就是apk包,读取apk中的资源文件进行替换就可以实现动态的换肤功能.

实现代码地址:https://github.com/JakePrim/PrimSkinCore

那么动态换肤的实现原理是什么? 我们知道在Activity和Fragment通过 setContentView 来加载布局文件,setContentView
中如何加载布局呢? 内部通过 LayoutInflater 布局加载器来加载布局. LayoutInflater
如何加载布局呢? 我们看如下系统的源代码:

  1. LayoutInflater.from(mContext).inflate(resId, contentParent);
  2. //inflate 做了什么呢? 点击看下inflate 方法做了什么
  3. public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
  4. .....
  5. //这里是获取返回的view
  6. final View temp = createViewFromTag(root, name, inflaterContext, attrs);
  7. ........
  8. }
  9. //在createViewFromTag返回了一个view
  10. View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
  11. boolean ignoreThemeAttr) {
  12. .....
  13. View view;
  14. if (mFactory2 != null) {//如果mFactory2 != null 则通过mFactory2加载
  15. view = mFactory2.onCreateView(parent, name, context, attrs);
  16. } else if (mFactory != null) {//如果mFactory != null 则通过mFactory加载
  17. view = mFactory.onCreateView(name, context, attrs);
  18. } else {
  19. view = null;
  20. }
  21. if (view == null && mPrivateFactory != null) {
  22. view = mPrivateFactory.onCreateView(parent, name, context, attrs);
  23. }
  24. //默认通过一下方式返回view
  25. if (view == null) {
  26. final Object lastContext = mConstructorArgs[0];
  27. mConstructorArgs[0] = context;
  28. try {
  29. if (-1 == name.indexOf('.')) {
  30. view = onCreateView(parent, name, attrs);
  31. } else {
  32. view = createView(name, null, attrs);
  33. }
  34. } finally {
  35. mConstructorArgs[0] = lastContext;
  36. }
  37. }
  38. return view;
  39. ......
  40. }

上述代码中首先判断mFactory2是否为空,如果不为空就交给了mFactory2去处理返回view,很明显google给了我们支持自定义的方法.setFactory2方法还是public的公有的方法,我们只需要实现Factory2,对view进行响应的处理然后返回就可以实现动态换肤. 在上述代码中还有一个 mFactory 其实是和mFactory2 一样的道理,实现mFactory也是一样的只不过 mFactory2处于第一个,防止有其他的地方实现了mFactory2,所以我们就需要去实现mFactory2,确保mFactory2被正确运行.

  1. /**
  2. * Like {@link #setFactory}, but allows you to set a {@link Factory2}
  3. * interface.
  4. */
  5. public void setFactory2(Factory2 factory) {
  6. if (mFactorySet) {
  7. throw new IllegalStateException("A factory has already been set on this LayoutInflater");
  8. }
  9. if (factory == null) {
  10. throw new NullPointerException("Given factory can not be null");
  11. }
  12. mFactorySet = true;
  13. if (mFactory == null) {
  14. mFactory = mFactory2 = factory;
  15. } else {
  16. mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
  17. }
  18. }

我们看了上述的系统源代码,知道了系统是支持自定义mFactory2,来自己实现对view对加载,返回view.加载view只需要参考系统的代码即可,核心的代码就是筛选属性,然后修改属性的值,来实现颜色和图片的改变.

那么系统是如何加载view的呢? 我们从系统的源代码中来查找,在 createViewFromTag 方法中name.indexOf(‘.’)如果布局中的view带. 这说明是自定义view,否则是基本的view,我们先来看一下基本的view,来看一下 onCreateView 方法

  1. if (view == null) {
  2. final Object lastContext = mConstructorArgs[0];
  3. mConstructorArgs[0] = context;
  4. try {
  5. if (-1 == name.indexOf('.')) {
  6. view = onCreateView(parent, name, attrs);
  7. } else {
  8. view = createView(name, null, attrs);
  9. }
  10. } finally {
  11. mConstructorArgs[0] = lastContext;
  12. }
  13. }

onCreateView其实是有AsyncLayoutInlater调用的onCreateView

  1. private static class BasicInflater extends LayoutInflater {
  2. private static final String[] sClassPrefixList = {
  3. "android.widget.",
  4. "android.webkit.",
  5. "android.app."
  6. };
  7. BasicInflater(Context context) {
  8. super(context);
  9. }
  10. @Override
  11. public LayoutInflater cloneInContext(Context newContext) {
  12. return new BasicInflater(newContext);
  13. }
  14. @Override
  15. protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
  16. for (String prefix : sClassPrefixList) {
  17. try {
  18. View view = createView(name, prefix, attrs);
  19. if (view != null) {
  20. return view;
  21. }
  22. } catch (ClassNotFoundException e) {
  23. // In this case we want to let the base class take a crack
  24. // at it.
  25. }
  26. }
  27. return super.onCreateView(name, attrs);
  28. }
  29. }

最核心的代码如下: 本质上是通过反射来实现返回View对象即可.是不是很简单,系统的源码就是这样加载布局的.

  1. if (constructor == null) {
  2. // Class not found in the cache, see if it's real, and try to add it
  3. clazz = mContext.getClassLoader().loadClass(
  4. prefix != null ? (prefix + name) : name).asSubclass(View.class);
  5. if (mFilter != null && clazz != null) {
  6. boolean allowed = mFilter.onLoadClass(clazz);
  7. if (!allowed) {
  8. failNotAllowed(name, prefix, attrs);
  9. }
  10. }
  11. constructor = clazz.getConstructor(mConstructorSignature);
  12. constructor.setAccessible(true);
  13. sConstructorMap.put(name, constructor);
  14. }

我们在得到view对象之后,获取view的属性将view的属性资源修改为皮肤包的资源.需要采集需要换肤的需要替换的属性.
采集如下基本属性,替换为皮肤包中的资源ID

  1. static {
  2. mAttributes.add("background");
  3. mAttributes.add("src");
  4. mAttributes.add("textColor");
  5. mAttributes.add("drawableLeft");
  6. mAttributes.add("drawableTop");
  7. mAttributes.add("drawableRight");
  8. mAttributes.add("drawableBottom");
  9. }

动态换肤的实现

在上述中我们知道通过布局加载器LayoutInflater来加载布局,所以我们需要每进入一个Activity拿到 LayoutInflater 设置自定义布局加载工厂,如果建立一个BaseActivity对代码对入侵性很强,Application中提供监听App的所有Activity的生命周期回调.

代码如下

  1. public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
  2. private HashMap<Activity, SkinLayoutFactory> mLayoutFactoryMap = new HashMap<>();
  3. @Override
  4. public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
  5. //拿到每个activity的布局加载器
  6. LayoutInflater layoutInflater = LayoutInflater.from(activity);
  7. //mFactorySet 如果为true 抛出异常,有可能会出现这种情况,通过反射将mFactorySet设置为false
  8. try {
  9. Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
  10. field.setAccessible(true);
  11. field.setBoolean(layoutInflater, false);
  12. } catch (Exception e) {
  13. e.printStackTrace();
  14. }
  15. //自定义布局处理工厂
  16. SkinLayoutFactory factory = new SkinLayoutFactory();
  17. //设置工厂
  18. LayoutInflaterCompat.setFactory2(layoutInflater, factory);
  19. }
  20. @Override
  21. public void onActivityStarted(Activity activity) {
  22. }
  23. @Override
  24. public void onActivityResumed(Activity activity) {
  25. }
  26. @Override
  27. public void onActivityPaused(Activity activity) {
  28. }
  29. @Override
  30. public void onActivityStopped(Activity activity) {
  31. }
  32. @Override
  33. public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
  34. }
  35. @Override
  36. public void onActivityDestroyed(Activity activity) {
  37. }
  38. }

接下来就是自定义 LayoutInflater.Factory2 ,Factory2的实现其实就是参考系统的实现,代码原理也非常简单,代码如下

  1. public class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
  2. private static final String[] sClassPrefixList = {
  3. "android.widget.",
  4. "android.webkit.",
  5. "android.app."
  6. };
  7. private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
  8. new HashMap<String, Constructor<? extends View>>();
  9. private static final Class<?>[] mConstructorSignature = new Class[]{
  10. Context.class, AttributeSet.class};
  11. private SkinAttribute skinAttribute;
  12. public SkinLayoutFactory() {
  13. skinAttribute = new SkinAttribute();
  14. }
  15. @Override
  16. public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
  17. //参考系统的实现
  18. View view = createViewFromTag(context, name, attrs);
  19. if (view == null) {
  20. view = createView(name, context, attrs);
  21. }
  22. //采集view的属性
  23. skinAttribute.load(view,attrs);
  24. return view;
  25. }
  26. @Override
  27. public View onCreateView(String name, Context context, AttributeSet attrs) {
  28. return null;
  29. }
  30. private View createViewFromTag(Context context, String name, AttributeSet attrs) {
  31. if (name.contains(".")) {//如果布局xml中的view 名包含.表示为自定义view 此处先不做处理
  32. return null;
  33. }
  34. View view = null;
  35. for (String prefix : sClassPrefixList) {//遍历view属于哪个包中 然后反射创建view对象
  36. try {
  37. view = createView(prefix + name, context, attrs);
  38. if (view != null) {
  39. break;
  40. }
  41. } catch (Exception e) {
  42. // In this case we want to let the base class take a crack
  43. // at it.
  44. }
  45. }
  46. return view;
  47. }
  48. private View createView(String name, Context context, AttributeSet attrs) {
  49. Constructor<? extends View> constructor = sConstructorMap.get(name);
  50. try {
  51. if (constructor == null) {
  52. Class<? extends View> clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);
  53. constructor = clazz.getConstructor(mConstructorSignature);
  54. constructor.setAccessible(true);
  55. sConstructorMap.put(name, constructor);
  56. }
  57. } catch (Exception e) {
  58. }
  59. if (null != constructor) {
  60. try {
  61. return constructor.newInstance(context, attrs);
  62. } catch (Exception e) {
  63. }
  64. }
  65. return null;
  66. }
  67. @Override
  68. public void update(Observable o, Object arg) {
  69. skinAttribute.applySkin();
  70. }
  71. }

真正实现换肤的就是,对Attribute的操作, 接下来看一下SkinAttribute的实现很非常简单,其实就是遍历所有view的属性拿到自己需要的属性,然后存储在集合中如下:

  1. //采集的属性集合
  2. static {
  3. mAttributes.add("background");
  4. mAttributes.add("src");
  5. mAttributes.add("textColor");
  6. mAttributes.add("drawableLeft");
  7. mAttributes.add("drawableTop");
  8. mAttributes.add("drawableRight");
  9. mAttributes.add("drawableBottom");
  10. }
  11. public void load(View view, AttributeSet attrs) {
  12. List<SkinPair> skinPairs = new ArrayList<>();
  13. //获取属性
  14. for (int i = 0; i < attrs.getAttributeCount(); i++) {
  15. //获取属性名
  16. String attributeName = attrs.getAttributeName(i);
  17. //匹配要修改的属性名
  18. if (mAttributes.contains(attributeName)) {
  19. //获取属性值
  20. String attributeValue = attrs.getAttributeValue(i);
  21. if (attributeValue.startsWith("#")) {//写死的先不去管 #000000
  22. continue;
  23. }
  24. int resId;
  25. if (attributeValue.startsWith("?")) {
  26. //attrId
  27. int attrId = Integer.parseInt(attributeValue.substring(1));
  28. resId = utils.getResId(view.getContext(), new int[]{attrId})[0];
  29. } else {
  30. //@1232311
  31. resId = Integer.parseInt(attributeValue.substring(1));
  32. }
  33. if (resId != 0) {
  34. //将匹配的都存储下来
  35. SkinPair skinPair = new SkinPair(attributeName, resId);
  36. skinPairs.add(skinPair);
  37. }
  38. }
  39. }
  40. //将布局中每个view与之对应的属性集合放入到对应的view集合中
  41. if (!skinPairs.isEmpty()) {
  42. SkinView skinView = new SkinView(view, skinPairs);
  43. //每次新加载activity就要进行换肤
  44. applySkin();
  45. skinViews.add(skinView);
  46. }
  47. }

至于SkinView类存储了属性名和资源ID和当前的view

  1. /**
  2. * 保存每个view要修改的属性值列表
  3. */
  4. static class SkinView {
  5. View view;
  6. List<SkinPair> skinPairs;
  7. public SkinView(View view, List<SkinPair> skinPairs) {
  8. this.view = view;
  9. this.skinPairs = skinPairs;
  10. }
  11. }
  12. static class SkinPair {
  13. String attrName;
  14. int resId;
  15. public SkinPair(String attrName, int resId) {
  16. this.attrName = attrName;
  17. this.resId = resId;
  18. }
  19. }

上述代码就是核心的实现,最后我们需要实现初始化和资源的切换,代码如下 通过皮肤包的路径,Application获取到资源管理器AssetManager获取皮肤包的资源,然后通过SkinResources来实现资源的加载,然后使SkinLayoutFactory做为观察者,当更换皮肤包时更新SkinLayoutFactory. 然后通过SkinAttribute将属性的资源更改为皮肤包的资源

  1. /**
  2. * 加载皮肤包
  3. *
  4. * @param path
  5. */
  6. public void loadSkin(String path) {
  7. if (path == null || path.isEmpty()) {
  8. SkinPreference.getInstance().setSkin("");
  9. SkinResources.getInstance().reset();
  10. } else {
  11. try {
  12. //获取资源管理器 加载皮肤包中的资源
  13. AssetManager assetManager = AssetManager.class.newInstance();
  14. Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
  15. addAssetPath.setAccessible(true);
  16. addAssetPath.invoke(assetManager, path);
  17. //加载皮肤包里的资源
  18. //获取当前应用的资源
  19. Resources resources = application.getResources();
  20. //皮肤包的资源
  21. Resources skinResources = new Resources(assetManager,
  22. resources.getDisplayMetrics(), resources.getConfiguration());
  23. //加载皮肤包资源
  24. //皮肤包的包名
  25. PackageManager packageManager = application.getPackageManager();
  26. //获取一个apk的包名
  27. PackageInfo info = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
  28. String packageName = info.packageName;
  29. Log.e(TAG, "loadSkin: " + packageName);
  30. //更换为皮肤包资源
  31. SkinResources.getInstance().applySkin(skinResources, packageName);
  32. //记录使用的皮肤包
  33. SkinPreference.getInstance().setSkin(path);
  34. } catch (Exception e) {
  35. e.printStackTrace();
  36. }
  37. }
  38. //通知观察者 //应用皮肤包
  39. setChanged();
  40. notifyObservers();
  41. }

SkinResources的实现,其实就是根据app的资源的资源名和资源类型,通过皮肤包的资源返回皮肤包的资源ID,如果没有就返回当前app的资源ID.

  1. public class SkinResources {
  2. //皮肤的资源
  3. private Resources mSkinResources;
  4. //当前应用的资源
  5. private Resources mAppResources;
  6. //皮肤包名
  7. private String mSkinName;
  8. //是否使用默认皮肤
  9. private boolean isDefalueSkin = true;
  10. public static SkinResources instance;
  11. public static void init(Context context) {
  12. if (null == instance) {
  13. synchronized (SkinResources.class) {
  14. if (null == instance) {
  15. instance = new SkinResources(context);
  16. }
  17. }
  18. }
  19. }
  20. public static SkinResources getInstance() {
  21. return instance;
  22. }
  23. public SkinResources(Context context) {
  24. mAppResources = context.getResources();
  25. }
  26. public void reset() {
  27. mSkinResources = null;
  28. mSkinName = "";
  29. isDefalueSkin = true;
  30. }
  31. public void applySkin(Resources resources, String name) {
  32. mSkinResources = resources;
  33. mSkinName = name;
  34. isDefalueSkin = false;
  35. }
  36. /**
  37. * 通过资源ID 获取皮肤包中对应的资源ID 然后进行设置
  38. *
  39. * @param resId
  40. * @return
  41. */
  42. public int getIdentfier(int resId) {
  43. if (isDefalueSkin) {
  44. return resId;
  45. }
  46. //通过当前应用资源获取到 资源名 和 资源类型,然后通过资源名 资源类型 资源ID 获取到皮肤包中的资源ID
  47. String resourceName = mAppResources.getResourceEntryName(resId);
  48. String resourceTypeName = mAppResources.getResourceTypeName(resId);
  49. //getIdentifier ?? 获取到皮肤包对应到资源ID
  50. int skinId = mSkinResources.getIdentifier(resourceName, resourceTypeName, mSkinName);
  51. return skinId;
  52. }
  53. private static final String TAG = "SkinResources";
  54. public int getColor(int resId) {
  55. if (isDefalueSkin) {
  56. return mAppResources.getColor(resId);
  57. }
  58. int skinId = getIdentfier(resId);
  59. Log.e(TAG, "getColor: " + skinId);
  60. if (skinId == 0) {
  61. return mAppResources.getColor(resId);
  62. }
  63. return mSkinResources.getColor(skinId);
  64. }
  65. public ColorStateList getColorStateList(int resId) {
  66. if (isDefalueSkin) {
  67. return mAppResources.getColorStateList(resId);
  68. }
  69. int skinId = getIdentfier(resId);
  70. if (skinId == 0) {
  71. return mAppResources.getColorStateList(resId);
  72. }
  73. return mSkinResources.getColorStateList(skinId);
  74. }
  75. public Drawable getDrawable(int resId) {
  76. //如果有皮肤 isDefaultSkin false 没有就是true
  77. if (isDefalueSkin) {
  78. return mAppResources.getDrawable(resId);
  79. }
  80. int skinId = getIdentfier(resId);
  81. if (skinId == 0) {
  82. return mAppResources.getDrawable(resId);
  83. }
  84. return mSkinResources.getDrawable(skinId);
  85. }
  86. /**
  87. * 可能是Color 也可能是drawable
  88. *
  89. * @return
  90. */
  91. public Object getBackground(int resId) {
  92. String resourceTypeName = mAppResources.getResourceTypeName(resId);
  93. if (resourceTypeName.equals("color")) {
  94. return getColor(resId);
  95. } else {
  96. // drawable
  97. return getDrawable(resId);
  98. }
  99. }
  100. public String getString(int resId) {
  101. try {
  102. if (isDefalueSkin) {
  103. return mAppResources.getString(resId);
  104. }
  105. int skinId = getIdentfier(resId);
  106. if (skinId == 0) {
  107. return mAppResources.getString(skinId);
  108. }
  109. return mSkinResources.getString(skinId);
  110. } catch (Resources.NotFoundException e) {
  111. }
  112. return null;
  113. }
  114. public Typeface getTypeface(int resId) {
  115. String skinTypefacePath = getString(resId);
  116. if (TextUtils.isEmpty(skinTypefacePath)) {
  117. return Typeface.DEFAULT;
  118. }
  119. try {
  120. Typeface typeface;
  121. if (isDefalueSkin) {
  122. typeface = Typeface.createFromAsset(mAppResources.getAssets(), skinTypefacePath);
  123. return typeface;
  124. }
  125. typeface = Typeface.createFromAsset(mSkinResources.getAssets(), skinTypefacePath);
  126. return typeface;
  127. } catch (RuntimeException e) {
  128. }
  129. return Typeface.DEFAULT;
  130. }
  131. }

当切换资源通知观察者SkinLayoutFactory的SkinAttribute 来采集属性和更换资源ID.代码的实现都非常简单

  1. /**
  2. * 更换皮肤
  3. */
  4. public void applySkin() {
  5. //遍历保存的表然后换皮肤
  6. for (SkinView skinView : skinViews) {
  7. skinView.applySkin();
  8. }
  9. }
  10. /**
  11. * 保存每个view要修改的属性值列表
  12. */
  13. static class SkinView {
  14. View view;
  15. List<SkinPair> skinPairs;
  16. public SkinView(View view, List<SkinPair> skinPairs) {
  17. this.view = view;
  18. this.skinPairs = skinPairs;
  19. }
  20. public void applySkin() {
  21. for (SkinPair skinPair : skinPairs) {
  22. Drawable left = null, right = null, top = null, bottom = null;
  23. switch (skinPair.attrName) {
  24. case "background":
  25. Object background = SkinResources.getInstance().getBackground(skinPair.resId);
  26. //可能是一个图片或者颜色
  27. if (background instanceof Integer) {
  28. //color 选择器
  29. view.setBackgroundColor((Integer) background);
  30. } else {
  31. ViewCompat.setBackground(view, (Drawable) background);
  32. }
  33. break;
  34. case "src":
  35. if (view instanceof ImageView) {
  36. ImageView imageView = (ImageView) view;
  37. Object background1 = SkinResources.getInstance().getBackground(skinPair.resId);
  38. if (background1 instanceof Integer) {
  39. imageView.setImageDrawable(new ColorDrawable((Integer) background1));
  40. } else {
  41. imageView.setImageDrawable((Drawable) background1);
  42. }
  43. }
  44. break;
  45. case "textColor":
  46. int color = SkinResources.getInstance().getColor(skinPair.resId);
  47. if (view instanceof TextView) {
  48. TextView textView = (TextView) view;
  49. textView.setTextColor(color);
  50. }
  51. break;
  52. case "drawableLeft":
  53. left = SkinResources.getInstance().getDrawable(skinPair.resId);
  54. break;
  55. case "drawableTop":
  56. top = SkinResources.getInstance().getDrawable(skinPair.resId);
  57. break;
  58. case "drawableRight":
  59. right = SkinResources.getInstance().getDrawable(skinPair.resId);
  60. break;
  61. case "drawableBottom":
  62. bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
  63. break;
  64. }
  65. if (null != left || null != right || null != top || null != bottom) {
  66. ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
  67. bottom);
  68. }
  69. }
  70. }
  71. }

OK,现在我们实现了基本的动态换肤功能,目前我们实现的换肤功能还没有实现自定义view和状态栏的换肤功能.下一节来讲解如何实现自定义view的换肤和状态栏的换肤,其实代码写到这里大家都知道了实现的原理可以先试着自己实现.