阅读 56

Android App实现动态换肤

准备阶段

  • 皮肤包是什么样的文件?
  • 动态换肤的思想是什么?
皮肤包是什么样的文件?

我们通过解析网易云音乐的皮肤包来理解

  1. 通过模拟器下载网易云音乐并更换皮肤。
  2. 在设备/data/data/com.netease.cloudmusic/files/theme目录下可以找到我们的皮肤包并cp到电脑上。
  3. 修改文件格式为zip,并解压。

经过上述步骤我们得到以下文件
网易云音乐皮肤包

我们可以看到,他的文件内容和我们平时apk的内容格式完全一致,那这样后续我们也可以同样方法来制作皮肤包。

动态换肤的方案是什么?- 缓存需要换肤的view,然后设置新样式

所有我们先要了解view的创建,下面我们从sdk源码中寻找答案,这里只看主要流程,不看其他 - 基于sdk版本30。

  • Activity
 public void setContentView(@LayoutRes int layoutResID) {
        // 调用window的setContentView
        getWindow().setContentView(layoutResID);
        initWindowDecorActionBar();
    }
  • Window - PhoneWindow
@Override
    public void setContentView(int layoutResID) {
          // 调用 LayoutInflater的inflate
          mLayoutInflater.inflate(layoutResID, mContentParent);
    }
  • LayoutInflater
@Override
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
         // Temp is the root view that was found in the xml  创建根布局
         final View temp = createViewFromTag(root, name, inflaterContext, attrs);
        // Inflate all children under temp against its context. 创建子布局 最后也是调用createViewFromTag
        rInflateChildren(parser, temp, attrs, true);
}

void rInflate(XmlPullParser parser, View parent, Context context, AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
      // 循环调用createViewFromTag创建子布局
       while (((type = parser.next()) != XmlPullParser.END_TAG ||parser.getDepth() >depth) && type != XmlPullParser.END_DOCUMENT) {
            final View view = createViewFromTag(parent, name, context, attrs);
        }
}

// 从这个方法中我们看到 尝试通过各种Factory来创建View
public final View tryCreateView(@Nullable View parent, @NonNull String name,@NonNull Context context,@NonNull AttributeSet attrs) {
        View view;
        if (mFactory2 != null) {
            view = mFactory2.onCreateView(parent, name, context, attrs);
        } else if (mFactory != null) {
            view = mFactory.onCreateView(name, context, attrs);
        } else {
            view = null;
        }
        if (view == null && mPrivateFactory != null) {
            view = mPrivateFactory.onCreateView(parent, name, context, attrs);
        }
        return view;
    }

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,boolean ignoreThemeAttr) {
      // 这里是比较重要的地方
        // 尝试通过Factory来创建View
        View view = tryCreateView(parent, name, context, attrs);
        // 如果没有Factory来创建,那么就调用下面方法创建View
         if (view == null) {
               if (-1 == name.indexOf('.')) {
                        // 系统提供的View 不带.的 比如View ,ImageView,TextView
                        view = onCreateView(context, parent, name, attrs);
               } else {
                        // 第三方View或者自定义view 比如com.cbb.xxxView
                        view = createView(context, name, null, attrs);
               }
         }  
    }
 
// 看到这里应该比较疑惑 为什么这里只传了android.view. ,很多view明明都不在这个包下
protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
        // 最终还是调用createView , 传入了系统view的全名
        return createView(name, "android.view.", attrs);
    }

public final View createView(@NonNull Context viewContext, @NonNull String name,@Nullable String prefix, @Nullable AttributeSet attrs)throws ClassNotFoundException, InflateException {
          // 缓存中获取View的构造方法
          Constructor<? extends View> constructor = sConstructorMap.get(name);
          // 没有缓存则反射获得View的构造方法 并缓存 
          // 需要注意的是 这里使用的view两个参数的构造方法
          if (constructor == null) {
                clazz = Class.forName(prefix != null ? (prefix + name) : name, false,
                        mContext.getClassLoader()).asSubclass(View.class);
                constructor = clazz.getConstructor(mConstructorSignature);
                constructor.setAccessible(true);
                sConstructorMap.put(name, constructor);
            }    
          // 使用构造方法创建view
          final View view = constructor.newInstance(args);
    }

// sdk提供了设置Factory 的方法
public void setFactory2(Factory2 factory) {
  // 这里需要注意mFactorySet 会被如果设置过会被设为true,所以后面我们在设置Factory前需要将其置为false
   if (mFactorySet) {
            throw new IllegalStateException("A factory has already been set on this LayoutInflater");
   }
   mFactorySet = true;
}

上述流程分析,我们了解到我们在setContentView开始到创建出view的过程,我们可看到系统在创建view之前会尝试用Factory来创建view,那么我们也可以通过设置自定义Factory来代替系统自带的创建。

上面分析中有个疑问,为什么这里只传了android.view. ,很多view明明都不在这个包下却可以成功创建?这里简单分析一下这个过程

public static LayoutInflater from(Context context) {
        LayoutInflater LayoutInflater =
                (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        return LayoutInflater;
    }

上面是LayoutInflater的实例化,我们看到实际返回的是context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
我们从源码中追溯上去

  • 传入的为Activity的context ,Activity的getSystemService(String name)
  • ContextThemeWrapper的getSystemService(String name)
  • ContextWrapper中getBaseContext().getSystemService(name)
  • getBaseContext()返回的mBase
  • ContextWrapper中的attachBaseContext(Context base)赋值
  • Activity中的attachBaseContext(context);
  • Activity中的attach()方法中attachBaseContext(context)
  • 在ActivityThread中performLaunchActivity方法中调用Activity的attach()方法
  • 在ActivityThread中performLaunchActivity方法中ContextImpl appContext = createBaseContextForActivity(r)实例化了Context
  • ContextImpl中getSystemService(String name)调用SystemServiceRegistry.getSystemService(this, name)返回;
  • 拿着LAYOUT_INFLATER_SERVICE去SystemServiceRegistry中寻找发现返回的是PhoneLayoutInflater类

经过上述步骤我们看到了实际返回的是PhoneLayoutInflater类

public class PhoneLayoutInflater extends LayoutInflater {
  private static final String[] sClassPrefixList = {
        "android.widget.",
        "android.webkit.",
        "android.app."
    };
 @Override protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
        for (String prefix : sClassPrefixList) {
         View view = createView(name, prefix, attrs);
             if (view != null) {
                    return view;
             }
        }
        return super.onCreateView(name, attrs);
    }
}

从上述PhoneLayoutInflater源码中可以看到PhoneLayoutInflater是LayoutInflater的子类,所以实际是拼接的这三个包下的,如果没有则就是原来的view包下。
上述疑问就得到了解决。

开始编码 (只贴出关键类与关键代码)

拦截系统view的创建
public class SkinLayoutFactory implements LayoutInflater.Factory2 {
    // 包目录列表
    private static final String[] sClassPrefixList = {
            "android.widget.",
            "android.webkit.",
            "android.app.",
            "android.view."
    };
    // view构造方法的两个参数
    private static final Class<?>[] mConstructorSignature = new Class[]{
            Context.class, AttributeSet.class};
    // 用户缓存已经反射获得的构造方法,防止后续同一个类型的view重复反射
    private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
            new HashMap<String, Constructor<? extends View>>();


    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        // 创建view
        View view = createViewFromTag(context, name, attrs);
        Log.e("Skin", "name = " + name + " , view = " + view);
        return view;
    }

    /**
     * 创建view
     * 通过判断是否包含.来确定是否区分两种view类型
     *
     * @param name 可能为TextView , 也可能为xxx.xxx.xxxView
     */
    private View createViewFromTag(Context context, String name, AttributeSet attrs) {
        View view;
        if (-1 == name.indexOf('.')) {
            view = createViewByPkgList(context, name, attrs);
        } else {
            view = createView(context, name, attrs);
        }
        return view;
    }

    /**
     * 通过遍历系统包来尝试创建view,如果上个没有创建成功有异常会被catch,然后继续尝试下一个包名来创建
     *
     * @param name 可能为TextView
     */
    private View createViewByPkgList(Context context, String name, AttributeSet attrs) {
        for (String prefix : sClassPrefixList) {
            try {
                View view = createView(context, prefix + name, attrs);
                if (view != null) {
                    return view;
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    /**
     * 真正的开始创建view
     *
     * @param name name 格式为xxx.xxx.xxxView
     */
    private View createView(Context context, String name, AttributeSet attrs) {
        Constructor<? extends View> constructor = sConstructorMap.get(name);
        if (null == constructor) {
            try {
                Class<? extends View> aClass = context.getClassLoader().loadClass(name).asSubclass
                        (View.class);
                constructor = aClass.getConstructor(mConstructorSignature);
                sConstructorMap.put(name, constructor);
            } catch (Exception e) {
            }
        }
        if (null != constructor) {
            try {
                return constructor.newInstance(context, attrs);
            } catch (Exception e) {
            }
        }
        return null;
    }

}

上述代码我们基本都是cp的系统源码,从而实现我们自己来创建view,现在我们要开始设置Factory,利用sdk提供的ActivityLifecycleCallbacks的来实现。

// 系统提供的可以监听整个app activity的生命周期
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
 @Override
    public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
        // 拿到对应的layoutInflater 创建skinLayoutFactory 并设置进去
        setFactory2(activity);
    }

    /**
     * 监听到activity 生命周期设置Factory 来拦截系统的view创建
     * 需要注意的地方为 需要将mFactorySet置为false
     * 这里有个缺陷 :>28 那么这个属性就不能使用反射来改变了 系统禁止了
     * 可以考虑直接反射来修改Factory的值 这个系统没有限制 这里没有实践
     */
    private void setFactory2(Activity activity){
        LayoutInflater layoutInflater = LayoutInflater.from(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);
    }
}
public class SkinManager {
    private static SkinManager instance;
    private Application application;
    private SkinActivityLifecycle skinActivityLifecycle;
    public static void init(Application application) {
        synchronized (SkinManager.class) {
            if (null == instance) {
                instance = new SkinManager(application);
            }
        }
    }
    public static SkinManager getInstance() {
        return instance;
    }
    private SkinManager(Application application) {
        this.application = application;
        //注册Activity生命周期回调
        skinActivityLifecycle = new SkinActivityLifecycle();
        application.registerActivityLifecycleCallbacks(skinActivityLifecycle);
    }
}

上述完成后,我们运行后可以输出
image.png

从这个我们可以我们拦截了系统view的创建来由我们自己创建,至于log输出的这个view列表,大家也应该很熟悉就是DecorView的结构,这里就不做赘述,到了这里我们已经完成了view的创建部分。

开始实现换肤
  1. 筛选需要的view
    上述我们已经拦截了所有的view,实际换肤只需要将需要换肤的view缓存下来就可以了,这里我们通过view的属性来筛选view。
// 只需要设置了这些属性的view
public class SkinAttribute {
static {
        mAttributes.add("background");
        mAttributes.add("src");
        mAttributes.add("textColor");
        mAttributes.add("drawableLeft");
        mAttributes.add("drawableTop");
        mAttributes.add("drawableRight");
        mAttributes.add("drawableBottom");
    }
  // 筛选view
    public void load(View view, AttributeSet attrs) {
        // 这个view 设置的可以被替换的属性列表
        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 = Integer.parseInt(attributeValue.substring(1));
                if (resId != 0) {
                    //可以被替换的属性
                    SkinPair skinPair = new SkinPair(attributeName, resId);
                    skinPairs.add(skinPair);
                }
            }
        }
        // 上述已经将这个view需要修改的属性保存进skinPairs了
        // 判断skinPairs是否为空 ,不为空就将这个view以后属性信息缓存起来
        if (!skinPairs.isEmpty() || view instanceof TextView) {
            SkinView skinView = new SkinView(view, skinPairs);
            // 去修改样式
            skinView.applySkin();
            mSkinViews.add(skinView);
        }
    }
 /**
     * 遍历view设置样式
     */
    public void applySkin() {
        for (SkinView mSkinView : mSkinViews) {
            mSkinView.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, 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);
                }
            }
        }
    }
// 用于保存属性名称和id
    static class SkinPair {
        String attributeName;
        int resId;
        public SkinPair(String attributeName, int resId) {
            this.attributeName = attributeName;
            this.resId = resId;
        }
    }
}

下面我们在创建view的地方进行筛选并缓存

public class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        // 创建view
        View view = createViewFromTag(context, name, attrs);
        Log.e("Skin", "name = " + name + " , view = " + view);
        //筛选符合属性的View
        skinAttribute.load(view, attrs);
        return view;
    }
}

到这里,我们创建view的时候通过SkinAttribute类我们可以筛选出可能需要更换皮肤的view,然后保存了每个view和其属性的对关系,我们需要替换的时候就遍历缓存的view然后重新设置的对应属性的。

  1. 制作皮肤包
  • 新建一个Android project/module
  • 将需要替换的颜色或者图片拷贝的项目中,需注意和原来项目的中的名称要一致。
  • 所有的都替换完成后,直接rebuild,拷贝出生成的apk包
  • 可以将名称改为任何你想要的,比如这里我修改为了theme.skin,这就是皮肤包了
  • 将其拷贝近手机文件下 - 实际应用中应该网络下载之类的
  1. 加载皮肤包
public class SkinManager extends Observable {
 /**
     * 使用皮肤包
     *
     * @param path 皮肤包地址
     */
    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 resources = application.getResources();
                // 外部资源 sResource
                Resources sResource= 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中方便后续查找
                SkinResources.getInstance().applySkin(sResource, packageName);
                //保存当前使用的皮肤包
                SkinPreference.getInstance().setSkin(path);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        //通知观察者
        setChanged();
        notifyObservers();
    }
}

上面代码将theme.skin加载了,SkinResources是一个工具类,这里传入了传入了外部皮肤包的Resources,举例作用如下

 // 根据本app中的资源id寻找皮肤包中的资源id
 public int getIdentifier(int resId) {
        if (isDefaultSkin) {
            return resId;
        }
        //在皮肤包中不一定就是 当前程序的 id
        //获取对应id 在当前的名称 colorPrimary
        // 所以要先获取当前名称和类型 再去皮肤包中查找对应的id
        String resName = mAppResources.getResourceEntryName(resId);
        String resType = mAppResources.getResourceTypeName(resId);
        int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);
        return skinId;
    }
// 根据资源id获得颜色
public int getColor(int resId) {
        // 如果显示默认皮肤 就返回默认的
        if (isDefaultSkin) {
            return mAppResources.getColor(resId);
        }
        // 获得在皮肤包的资源id  两个包中的统一名称资源可能id不一样
        int skinId = getIdentifier(resId);
        if (skinId == 0) {
        // 返回皮肤包中的资源
            return mAppResources.getColor(resId);
        }
        return mSkinResources.getColor(skinId);
    }

这里加载完皮肤包后,我们需要做的就是通知到view去更新,这里代码就不贴出来了

  • 已经有的页面通知SkinAttribute调用applySkin()去遍历已经缓存的view去设置
  • 后续打开的页面,包括退出重新进入app,那么就要在SkinLayoutFactory调用onCreateView创建view的时候调用SkinAttribute的load方法去设置

至此我们可以实现一些基本的功能,测试一波
原图1

原图2

替换过后...
替换1
替换2

这里已经成功的实现了换肤,退出后重新进入也是显示设置皮肤,但是很多地方也许不够完善,这里只是阐明一个方法,而且关于字体和状态栏都没有替换,后续将继续去替换字体和状态栏部分。

作者:小名坎坎

原文链接:https://www.jianshu.com/p/7481aa54e9f4

文章分类
后端
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐