当前位置:   article > 正文

Android 动态换肤技术原理实践及总结

app:skin_enable

此文来自阿钟的投稿,阅读大约15分钟

实现的效果图

动态换肤一般实现的原理

  1. 对页面需要换肤的View进行标记

  2. Activity#setContentView()加载view时获取到标记的view(后面会说是要怎么获取到)

  3. 创建一个Library项目制作我们的皮肤包(res下的资源名称需要与app使用的一致,换肤就是通过使用的资源名称去皮肤包里加载相同名字的资源)

  4. 创建皮肤包对应的Resources对象(用于加载皮肤包内的资源)

  5. 点击换肤将我们标记的View的一些属性上设置的值修改为皮肤包里的值,这样就达到换肤的效果

一、对页面需要换肤的View进行标记

这一步是相对简单的,只要自定义一个属性即可;在获取View的时候判断有无这个属性 有就将这个view存起来

  1. 1<?xml version="1.0" encoding="utf-8"?>
  2. 2<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3. 3    xmlns:skin="http://schemas.android.com/apk/azhon-skin"
  4. 4    xmlns:tools="http://schemas.android.com/tools"
  5. 5    android:layout_width="match_parent"
  6. 6    android:layout_height="match_parent"
  7. 7    android:orientation="vertical"
  8. 8    android:padding="16dp"
  9. 9    tools:context=".MainActivity">
  10. 10    <TextView
  11. 11        android:layout_width="wrap_content"
  12. 12        android:layout_height="wrap_content"
  13. 13        android:background="@color/bg_1"
  14. 14        android:text="我是一个TextView"
  15. 15        android:textColor="@color/title_1"
  16. 16        android:textSize="16sp"
  17. 17        skin:enable="true" />
  18. 18    <Button
  19. 19        android:id="@+id/btn_dark"
  20. 20        android:layout_width="match_parent"
  21. 21        android:layout_height="wrap_content"
  22. 22        android:layout_marginTop="20dp"
  23. 23        android:background="@color/bg_2"
  24. 24        android:text="@string/btn_text"
  25. 25        android:textColor="@color/title_2"
  26. 26        skin:enable="true" />
  27. 27    <Button
  28. 28        android:id="@+id/btn_default"
  29. 29        android:layout_width="match_parent"
  30. 30        android:layout_height="wrap_content"
  31. 31        android:layout_marginTop="20dp"
  32. 32        android:background="@color/bg_3"
  33. 33        android:text="@string/btn_reset_text"
  34. 34        android:textColor="@color/title_3" />
  35. 35</LinearLayout>

skin:enable="true"这个就是自定的一个属性取值为boolean,如果为true就表示在换肤的时候需要去皮肤包加载对应的资源 ¨K15K **这里使用的是自定义布局加载器LayoutInflaterLayoutInflater.Factory2 `来监听View的创建;下面我们来通过阅读源码来具体说一下为什么使用的这个:**

查看AppCompatActivity的setContentView()方法

  1. 1public class MainActivity extends AppCompatActivity {
  2. 2    @Override
  3. 3    protected void onCreate(Bundle savedInstanceState) {
  4. 4        super.onCreate(savedInstanceState);
  5. 5        setContentView(R.layout.activity_main);
  6. 6}

接着继续调用了getDelegate()的setContentView()方法

  1. 1// AppCompatActivity.java
  2. 2@Override
  3. 3public void setContentView(@LayoutRes int layoutResID) {
  4. 4    getDelegate().setContentView(layoutResID);
  5. 5}

getDelegate()获取到的是AppCompatDelegate这个抽象类的实现类,而他的实现类就只有一个AppCompatDelegateImpl

接着调用了AppCompatDelegateImpl的setContentView()

  1. 1// AppCompatDelegateImpl.java
  2. 2@Override
  3. 3public void setContentView(int resId) {
  4. 4    ensureSubDecor();
  5. 5    ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
  6. 6    contentParent.removeAllViews();
  7. 7    //重点就是这样代码,通过布局加载器加载xml文件
  8. 8    LayoutInflater.from(mContext).inflate(resId, contentParent);
  9. 9    mAppCompatWindowCallback.getWrapped().onContentChanged();
  10. 10}

阅读到这里就可以看到有用的代码了LayoutInflater.from(mContext).inflate(resId, contentParent)加载我们的xml布局文件,他传入了我们的布局资源id`和android.R.id.content这个ViewGroup;有了解过Activity的布局层次结构的同学肯定就知道是什么了。

接着往下看LayoutInflater的inflate()方法

  1. 1// LayoutInflater.java
  2. 2//No.1
  3. 3public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
  4. 4    return inflate(resource, root, root != null);
  5. 5}
  6. 6//No.2 接着调用了
  7. 7public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
  8. 8    final Resources res = getContext().getResources();
  9. 9    if (DEBUG) {
  10. 10        Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
  11. 11                + Integer.toHexString(resource) + ")");
  12. 12    }
  13. 13    final XmlResourceParser parser = res.getLayout(resource);
  14. 14    try {
  15. 15        return inflate(parser, root, attachToRoot);
  16. 16    } finally {
  17. 17        parser.close();
  18. 18    }
  19. 19}
  20. 20//No.3 接着调用了
  21. 21public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
  22. 22    synchronized (mConstructorArgs) {
  23. 23        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");
  24. 24            // 省略若干源代码.... 
  25. 25            if (TAG_MERGE.equals(name)) {
  26. 26                if (root == null || !attachToRoot) {
  27. 27                    throw new InflateException("<merge /> can be used only with a valid "
  28. 28                            + "ViewGroup root and attachToRoot=true");
  29. 29                }
  30. 30                rInflate(parser, root, inflaterContext, attrs, false);
  31. 31            } else {
  32. 32                // Temp is the root view that was found in the xml
  33. 33                final View temp = createViewFromTag(root, name, inflaterContext, attrs);
  34. 34                ViewGroup.LayoutParams params = null;
  35. 35
  36. 36             // 省略若干源代码.... 
  37. 37
  38. 38            }
  39. 39      }
  40. 40}

调用inflate()最终调用了createViewFromTag()这个方法根据布局写的代码开始创建对应的View实体,继续向下查看createViewFromTag()的代码

  1. 1// LayoutInflater.java
  2. 2// No.1
  3. 3private View createViewFromTag(View parent, String name, Context context, AttributeSet attrs) {
  4. 4    return createViewFromTag(parent, name, context, attrs, false);
  5. 5}
  6. 6// No.2
  7. 7View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
  8. 8        boolean ignoreThemeAttr) {
  9. 9
  10. 10   // 省略若干源代码.... 
  11. 11
  12. 12    try {
  13. 13        View view;
  14. 14        if (mFactory2 != null) {
  15. 15            view = mFactory2.onCreateView(parent, name, context, attrs);
  16. 16        } else if (mFactory != null) {
  17. 17            view = mFactory.onCreateView(name, context, attrs);
  18. 18        } else {
  19. 19            view = null;
  20. 20        }
  21. 21        if (view == null && mPrivateFactory != null) {
  22. 22            view = mPrivateFactory.onCreateView(parent, name, context, attrs);
  23. 23        }
  24. 24        if (view == null) {
  25. 25            final Object lastContext = mConstructorArgs[0];
  26. 26            mConstructorArgs[0] = context;
  27. 27            try {
  28. 28                if (-1 == name.indexOf('.')) {
  29. 29                    view = onCreateView(parent, name, attrs);
  30. 30                } else {
  31. 31                    view = createView(name, null, attrs);
  32. 32                }
  33. 33            } finally {
  34. 34                mConstructorArgs[0] = lastContext;
  35. 35            }
  36. 36        }
  37. 37        return view;
  38. 38    } catch (InflateException e) {
  39. 39        throw e;
  40. 40    } 
  41. 41    // 省略若干源代码....
  42. 42}

代码查看到这里终于看到了开头所说的Factory这个东西,上面代码最终通过调用onCreateView()来创建view;所以我们只需要对LayoutInflater设置一个Factory即可。

先来看看设置setFactory()的方法

  1. 1// LayoutInflater.java
  2. 2public void setFactory(Factory factory) {
  3. 3    if (mFactorySet) {
  4. 4        throw new IllegalStateException("A factory has already been set on this LayoutInflater");
  5. 5    }
  6. 6    if (factory == null) {
  7. 7        throw new NullPointerException("Given factory can not be null");
  8. 8    }
  9. 9    mFactorySet = true;
  10. 10    if (mFactory == null) {
  11. 11        mFactory = factory;
  12. 12    } else {
  13. 13        mFactory = new FactoryMerger(factory, null, mFactory, mFactory2);
  14. 14    }
  15. 15}

可以很清楚的看到,如果我们调用了这个方法那么肯定会抛出一个异常IllegalStateException ,A factory has already been set on this LayoutInflater,所以设置之前我们需要通过反射将mFactorySet这个变量置为false


需要注意的一点:

既然是干预View的加载创建,那肯定设置Factory需要在LayoutInflater实例创建之后,在加载创建View之前;而Activity是通过setContentView()加载View所以设置Factory需要在setContentView()之前;这里可以通过Application设置Activity的生命周期监听器,即

registerActivityLifecycleCallbacks()

上面bb了一堆现在来上代码了

  1. 1public class App extends Application {
  2. 2    @Override
  3. 3    public void onCreate() {
  4. 4        super.onCreate();
  5. 5        registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
  6. 6            @Override
  7. 7            public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
  8. 8                setFactory(activity);
  9. 9            }
  10. 10            @Override
  11. 11            public void onActivityStarted(Activity activity) {
  12. 12            }
  13. 13            @Override
  14. 14            public void onActivityResumed(Activity activity) {
  15. 15            }
  16. 16            @Override
  17. 17            public void onActivityPaused(Activity activity) {
  18. 18            }
  19. 19            @Override
  20. 20            public void onActivityStopped(Activity activity) {
  21. 21            }
  22. 22            @Override
  23. 23            public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
  24. 24            }
  25. 25            @Override
  26. 26            public void onActivityDestroyed(Activity activity) {
  27. 27            }
  28. 28        });
  29. 29    }
  30. 30}

创建SkinFactory.java

  1. 1public final class SkinFactory implements LayoutInflater.Factory2 {
  2. 2    private static final String TAG = "SkinFactory";
  3. 3    private static final String[] classPrefixList = {"android.view.""android.widget.""android.webkit."};
  4. 4    private static final String NAME_SPACE = "http://schemas.android.com/apk/azhon-skin";
  5. 5    private static final String ATTRIBUTE = "enable";
  6. 6    @Override
  7. 7    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
  8. 8
  9. 9        //找到布局使用属性(skin:enable="true")标记需要换肤的view
  10. 10        boolean isSkinView = attrs.getAttributeBooleanValue(NAME_SPACE, ATTRIBUTE, false);
  11. 11        //如果不是换肤的View就直接不处理
  12. 12        if (!isSkinView) return null;
  13. 13        View view = null;
  14. 14        //name不包含.的说明是系统的控件
  15. 15        if (-1 == name.indexOf('.')) {
  16. 16            for (String prefix : classPrefixList) {
  17. 17                view = createView(name, prefix, context, attrs);
  18. 18                if (view != null) break;
  19. 19            }
  20. 20        } else {
  21. 21            view = createView(name, null, context, attrs);
  22. 22        }
  23. 23        LogUtil.d(TAG, "onCreateView: 加载换肤View成功..." + view);
  24. 24        return view;
  25. 25    }
  26. 26
  27. 27    /**
  28. 28     * 创建系统自带View
  29. 29     */
  30. 30    private View createView(String name, String prefix, Context context, AttributeSet attrs) {
  31. 31        View view = null;
  32. 32        try {
  33. 33            view = LayoutInflater.from(context).createView(name, prefix, attrs);
  34. 34        } catch (ClassNotFoundException e) {
  35. 35            //
  36. 36        }
  37. 37        return view;
  38. 38    }
  39. 39    @Override
  40. 40    public View onCreateView(String name, Context context, AttributeSet attrs) {
  41. 41        return null;
  42. 42    }
  43. 43}

设置Factory

  1. 1/**
  2. 2 * 设置布局解析Factory
  3. 3 * 需要将LayoutInflater的mFactorySet变量设置为false
  4. 4 */
  5. 5private void setFactory(Activity activity) {
  6. 6    try {
  7. 7        LayoutInflater inflater = activity.getLayoutInflater();
  8. 8        Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
  9. 9        field.setAccessible(true);
  10. 10        field.setBoolean(inflater, false);
  11. 11        //设置自己的Factory
  12. 12        LayoutInflaterCompat.setFactory2(inflater, new SkinFactory());
  13. 13    } catch (Exception e) {
  14. 14        e.printStackTrace();
  15. 15    }
  16. 16}

在SkinFactory#onCreateView()中就可以获取到我们标记的View了,这里需要保存换肤的View,需要替换的属性和属性的值

三、创建一个Library项目制作皮肤包资源

app默认的颜色资源

图片

对应的皮肤包如下:

图片

作为皮肤包只需要res目录可以将java的目录代码全部删除

皮肤包中定义的资源名称必须与主app定义的一模一样

然后通过AS的菜单——>Build——>Build Bundle(s) / APK(s)——> Build APK(s)就可以打包出来了

四、有了皮肤包资源就可以创建`Resources`对象拿到`res/`下的所有资源

创建Resources对象

  1. 1/**
  2. 2 * 创建皮肤包的Resources
  3. 3 *
  4. 4 * @param path 皮肤包路径
  5. 5 */
  6. 6public void createResources(Context context, String path) {
  7. 7    try {
  8. 8        AssetManager assetManager = AssetManager.class.newInstance();
  9. 9        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
  10. 10        addAssetPath.invoke(assetManager, path);
  11. 11        Resources resources = context.getResources();
  12. 12        //创建对象
  13. 13        Resources skinResources = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());
  14. 14        //获取皮肤包(也就是apk)的包名
  15. 15        PackageManager packageManager = context.getPackageManager();
  16. 16        PackageInfo packageInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
  17. 17        String skinPackageName = packageInfo.packageName;
  18. 18    } catch (Exception e) {
  19. 19        e.printStackTrace();
  20. 20    }
  21. 21}

path 就是皮肤包路径了

/sdcard/Android/data/com.azhon.dynamicskin/cache/dark.skin
通过PackageManager获取皮肤包的包名,包名在获取皮肤包内的资源时会用到

五、加载皮肤包内的资源,下面通过一个示例来讲解

我们需要替换这个TextView的background,textColor这两个属性

  1. 1<TextView
  2. 2    android:layout_width="wrap_content"
  3. 3    android:layout_height="wrap_content"
  4. 4    android:background="@color/bg_1"
  5. 5    android:text="我是一个TextView"
  6. 6    android:textColor="@color/title_1"
  7. 7    android:textSize="16sp"
  8. 8    skin:enable="true" />

在自定义的SkinFactory中就可以获取每一个属性和属性对应的值,如下:

图片

这里的@开头值后面的数字就是res下的资源对应的Id(也是就是R文件的Id)

先介绍一个重要的(api)方法

int resId = resources.getIdentifier(String name, String defType, String defPackage);
第一个参数:资源的名字,例如:bg_1、titile_1
第二个参数:资源类型,例如:drawable、color、string
第三个参数:resources资源对应的包名

根据资源id加载皮肤包内对应的资源

封装的方法

  1. 1/**
  2. 2 * 根据资源Id获取资源的名称
  3. 3 * @param resources     app自身的资源对象
  4. 4 * @param skinResources 皮肤包创建的资源对象
  5. 5 * @param id            当前使用的资源id
  6. 6 */
  7. 7public static int getResourcesIdByName(Resources resources,Resources skinResources, String packageName, int id) {
  8. 8    String[] res = getResourcesById(resources, id);
  9. 9    //使用皮肤包创建的Resources加载资源
  10. 10    return skinResources.getIdentifier(res[0], res[1], packageName);
  11. 11}
  12. 12
  13. 13/**
  14. 14 * 根据资源Id获取资源的名称
  15. 15 *
  16. 16 * @param id 资源id
  17. 17 * @return 资源名称
  18. 18 */
  19. 19public static String[] getResourcesById(Resources resources, int id) {
  20. 20    String entryName = resources.getResourceEntryName(id);
  21. 21    String typeName = resources.getResourceTypeName(id);
  22. 22    return new String[]{entryName, typeName};
  23. 23}

获取对应皮肤包内的资源id(2130968664就是获取到的资源id)

1int skinResId = getResourcesIdByName(context.getResources(),skinResources,skinPackageName,2130968664);

获取到了资源的id,但是这个值是不能直接使用的需要在进一步操作
上面通过getResourcesById()这个方法知道了这个资源id是属于color类型的了,所以只要在调用一次getColor即可

1int color = skinResources.getColor(skinResId);

通过上面几步就成功的拿到了皮肤包内对应的资源,最后就只要调用TextView的setTextColor(color)就可以成功的替换文字的颜色了,同理替换background也是一样的。

Resources也还提供了许多其它的方法:

图片

Demo下载:

https://gitee.com/azhon/Resources/blob/master/DynamicSkin.zip

需要将项目根目录的 dark.skin 文件拷贝至

/sdcard/Android/data/com.azhon.dynamicskin/cache/目录下 ¨K32K <ul> <li>干预View的加载创建,Factory的原理和使用</li> <li>对一个apk包创建对应的Resources对象AssetManagerPackageManager的使用</li> <li>加载apk包内的资源,Resources`的使用

我创建了一个关于Android的交流群,有兴趣可以加我微信我拉你

图片

如果感觉现在的网络技术文章质量不高,苦于自己的Android技术无法得到明显的提升,感叹没有一帮好的学习伙伴及道友,那么我的知识星球可能就是一片净土,好的学习气氛,更好的技术资源与文章,自由且高效率,快来吧。

图片

点击阅读原文,获得更多精彩内容

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/blog/article/detail/86914
推荐阅读
相关标签
  

闽ICP备14008679号