Android OtherAndroidAndroid

插件化换肤

2022-02-28  本文已影响0人  w达不溜w

插件化换肤的优点

1)换肤无闪烁,立即生效,无需重启APP,用户体验好
2)扩展和维护方便,入侵性小,低耦合
3)插件化开发,任何APP可以是你的皮肤包

思路

换肤就是在需要时候替换 View的属性(src、background、textColor等),利用Android加载资源的流程,来加载第三方皮肤包。

1、收集XML数据

如何去收集布局文件中的信息呢?通过查看setContentView源码分析,利用View的实例化流程,替换LayoutInflater类中的mFactory2变量,mFactory2在Activity启动之前就已经赋值了,LayoutInflater提供了修改mFactory2的入口(setFactory2方法)

public void setFactory2(Factory2 factory) {
   //调用setFactory2之后,再次调用会抛出异常
   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,所以设置自定义的Factory2需要修改mFactorySet为false
   mFactorySet = true;
   if (mFactory == null) {
     mFactory = mFactory2 = factory;
   } else {
     mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
   }
 }

自定义SkinLayoutInflaterFactory用来接管系统的View的生产过程

public class SkinLayoutInflaterFactory implements LayoutInflater.Factory2{

    // 当选择新皮肤后需要替换View与之对应的属性
    // 页面属性管理器
    private SkinAttribute skinAttribute;
    // 用于获取窗口的状态框的信息
    private Activity activity;
        //仿系统AppCompatViewInflater具体实例化View
    private SkinAppCompatViewInflater mAppCompatViewInflater;

    public SkinLayoutInflaterFactory(Activity activity) {
        this.activity = activity;
        skinAttribute = new SkinAttribute();
    }

    @Override
    public View onCreateView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs) {  
        if (mAppCompatViewInflater == null) {
            //可以处理不同版本实例化View (仿照系统类AppCompatViewInflater)
            mAppCompatViewInflater = new SkinAppCompatViewInflater();
        }
      //所以这里创建 View,从而修改View属性
        View view = mAppCompatViewInflater.createView(parent, name, context, attrs, false,
                false,
                true,
                VectorEnabledTintResources.shouldBeUsed());
        //这就是我们加入的逻辑,收集xml数据
        if (null != view) {
            //记录属性
            skinAttribute.look(view, attrs);
        }
        return view;
    }


    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return null;
    }
}

使用factory2 设置布局加载工厂

try {
  //上面提到过:Android布局加载器使用mFactorySet标记是否设置过Factory,如设置过抛出一次
  //所以需要通过反射设置mFactorySet为false
  Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
  field.setAccessible(true);
  field.setBoolean(layoutInflater, false);
} catch (Exception e) {
  e.printStackTrace();
}
//使用factory2设置布局加载工厂
SkinLayoutInflaterFactory skinLayoutInflaterFactory = new SkinLayoutInflaterFactory
  (activity);
LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutInflaterFactory);

对于android Q上无法二次setFactroy的问题,同样的思路,反射LayoutInflaterCompat中的sCheckedField字段设置为false和修改mFactory2的值

2、记录需要换肤的属性

上面在收集xml数据的时候已经记录了View对应的属性,通过遍历筛选并记录需要换肤的属性

//需要换肤的属性集合
private static final List<String> mAttributes = new ArrayList<>();
static {
  mAttributes.add("background");
  mAttributes.add("src");
  mAttributes.add("textColor");
  mAttributes.add("drawableLeft");
  //...
}
//遍历筛选需要操作的属性信息
public void look(View view, AttributeSet attrs) {
  //SkinPair记录一个属性(属性名--对应资源id)
  List<SkinPair> mSkinPars = new ArrayList<>();
  for (int i = 0; i < attrs.getAttributeCount(); i++) {
    //获得属性名,如:textColor
    String attributeName = attrs.getAttributeName(i);
    if (mAttributes.contains(attributeName)) {
      String attributeValue = attrs.getAttributeValue(i);
      // 比如color 以#开头表示写死的颜色 不可用于换肤
      if (attributeValue.startsWith("#")) {
        continue;
      }
      int resId;
      // 以 ?开头的表示使用 属性
      if (attributeValue.startsWith("?")) {
        int attrId = Integer.parseInt(attributeValue.substring(1));
        resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
      } else {
        // 正常以 @ 开头
        resId = Integer.parseInt(attributeValue.substring(1));
      }
      SkinPair skinPair = new SkinPair(attributeName, resId);
      mSkinPars.add(skinPair);
    }
  }
  if (!mSkinPars.isEmpty() || view instanceof SkinViewSupport) {
    //SkinView记录一个View对应多个属性(View--List<SkinPars>)
    SkinView skinView = new SkinView(view, mSkinPars);
    // 如果选择过皮肤 ,调用一次applySkin加载皮肤的资源
    skinView.applySkin();
    //mSkinViews记录多个View(List<SkinView>)
    mSkinViews.add(skinView);
  }
}
3、加载皮肤包资源

先来分析Resource创建过程:

  ActivityThread#handleBindApplication()
> final ContextImpl appContext = ContextImpl.createAppContext(this, data.info)
> context.setResources(packageInfo.getResources())
> mResources = ResourcesManager.getInstance().getResources(...)
> return getOrCreateResources(activityToken, key, classLoader)
> ResourcesImpl resourcesImpl = createResourcesImpl(key)
> final AssetManager assets = createAssetManager(key)

最终创建了一个AssetManager对象去加载资源,我们可以利用反射执行AssetManager的addAssetPath方法去设置皮肤包的路径,然后创建一个Resource对象去加载皮肤包中的资源

try {
  //宿主app的 resources;
  Resources appResource = mContext.getResources();
  //反射创建AssetManager 与 Resource
  AssetManager assetManager = AssetManager.class.newInstance();
  //反射获取addAssetPath方法 资源路径设置 目录或压缩包 
  Method addAssetPath = assetManager.getClass().getMethod("addAssetPath",String.class);
  //反射调用addAssetPath方法
  addAssetPath.invoke(assetManager, skinPath);

  //根据当前的设备显示器信息 与 配置(横竖屏、语言等) 创建Resources
  Resources skinResource = new Resources(assetManager, appResource.getDisplayMetrics
       (), appResource.getConfiguration());

  //获取外部Apk(皮肤包) 包名
  PackageManager mPm = mContext.getPackageManager();
  PackageInfo info = mPm.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES);
  String packageName = info.packageName;
  //设置皮肤
  SkinResources.getInstance().applySkin(skinResource, packageName);
} catch (Exception e) {
  e.printStackTrace();
}
4、修改属性的值(属性的值来自皮肤包)

AssetManager中提供了获取资源id的核心方法:

//获取资源id
@AnyRes int getResourceIdentifier(@NonNull String name, @Nullable String defType,
            @Nullable String defPackage)
//通过资源id获取资源类型名,如drawable,mipmap
@Nullable String getResourceTypeName(@AnyRes int resId)
//通过资源id获取资源名
@Nullable String getResourceEntryName(@AnyRes int resId)

上面已经创建到了皮肤包的Resource对象,我们可以根据资源名、资源类型和包名获取皮肤包中的资源id,然后可以皮肤包中资源id获取对应属性值

public int getIdentifier(int resId){
    String resName=mAppResources.getResourceEntryName(resId);
    String resType=mAppResources.getResourceTypeName(resId);
    int skinId=mSkinResources.getIdentifier(resName,resType,mSkinPkgName);
    return skinId;
}
//根据主APP的ID,到皮肤APK文件中去找到对应ID的颜色值
public int getColor(int resId){
  if(isDefaultSkin){
    return mAppResources.getColor(resId);
  }
  int skinId=getIdentifier(resId);
  if(skinId==0){
    return mAppResources.getColor(resId);
  }
  return mSkinResources.getColor(skinId);
}

然后对View中所有支持的属性进行修改

public void applySkin() {
  for (SkinPair skinPair : skinPairs) {
    switch (skinPair.attributeName) {
      case "textColor":
        ((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList
                    (skinPair.resId));
        break;
        //...
      default:
        break;
    }
  }
}
上一篇下一篇

猜你喜欢

热点阅读