面试题三方架构分析

Android 换肤那些事儿, Resource包装流 ?Ass

2019-07-29  本文已影响0人  张小凡凡

一、Res资源加载流程

应用资源加载的过程 主要涉及两个类: Resource只与应用程序交互,负责加载资源的管理等等;AssetManager负责res目录中所有的资源文件,打开文件,并读取到内存中。

当使用Context.getDrawable()方法 通过资源ID 生成一个Drawable对象时,最终会调用到Resource的getDrawable(...)方法。

    public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme)
            throws NotFoundException {
        return getDrawableForDensity(id, 0, theme);
    }

内部函数调用如下:

getDrawable()函数调用.jpg

ResourcesImpl 是Resource内部的一个静态代理类,实际负责与AssetManager的交互。

在loadDrawableForCookie() 方法中真正开始加载资源,假如该id 对应的是一个xml文件,则开始xml解析,假如该id对应的一个图片文件,则调用AssetManager打开文件。

AssetManager实际上调用Native方法打开文件。

    public @NonNull InputStream openNonAsset(...) {
        synchronized (this) {
            final long asset = nativeOpenNonAsset(mObject, cookie, fileName, accessMode);
            final AssetInputStream assetInputStream = new AssetInputStream(asset);
            return assetInputStream;
        }
    }

二、AssetManager添加Res目录

要使用AssetManager可以打开res目录中资源文件,必须把res路径添加到AssetManager的path中。这里主要分两步:添加系统资源 路径 和 apk资源文件路径。

第一,添加系统资源。在程序进程创建时,由zygote进程调用createSystemAssetsInZygoteLocked(...)方法,添加到AssetManager中。FRAMEWORK_APK_PATH 即为系统资源路径。

    private static final String FRAMEWORK_APK_PATH = "/system/framework/framework-res.apk";
    /**
     * This must be called from Zygote so that system assets are shared by all applications.
     */
    @GuardedBy("sSync")
    private static void createSystemAssetsInZygoteLocked() {
        try {
            final ArrayList<ApkAssets> apkAssets = new ArrayList<>();
            apkAssets.add(ApkAssets.loadFromPath(FRAMEWORK_APK_PATH, true /*system*/));
            loadStaticRuntimeOverlays(apkAssets);

            sSystemApkAssetsSet = new ArraySet<>(apkAssets);
            sSystemApkAssets = apkAssets.toArray(new ApkAssets[apkAssets.size()]);
            sSystem = new AssetManager(true /*sentinel*/);
            sSystem.setApkAssets(sSystemApkAssets, false /*invalidateCaches*/);
        } catch (IOException e) {
            throw new IllegalStateException("Failed to create system AssetManager", e);
        }
    }

第二,添加apk资源目录。应用程序进程启动后,由AMS调用 创建Application时,会间接调用到ResourcesManager中的createAssetManager()方法,创建AssetManager对象时,添加apk资源相关目录。

protected @Nullable AssetManager createAssetManager(@NonNull final ResourcesKey key){
        final AssetManager.Builder builder = new AssetManager.Builder();
        ......
        if (key.mLibDirs != null) {
            for (final String libDir : key.mLibDirs) {
                if (libDir.endsWith(".apk")) {
                    try {
                        builder.addApkAssets(loadApkAssets(libDir, true /*sharedLib*/,
                                false /*overlay*/));
                    } catch (IOException e) {
                    }
                }
            }
        }
         ......
        return builder.build();
    }

在AssetManager 中添加Res目录 正是应用换肤功能得以实现的第一步,只有将皮肤包的Res文件路径 添加到 AssetManager的Path中,应用才有可能获取到皮肤包内资源文件。

三、Resource包装流 解决方案

这个方案的思路在于拦截应用中 对于Resource对象的操作。即拦截ContextImp中的Resource对象。
ps: Context分析文章见:Android 应用启动那些事儿,Application? Context?

1, 创建Resource对象

创建AssetManager对象,并将皮肤包资源路径添加到 AssetManager的Path数组中。(AssetManager.addAssetPath(...)方法为隐藏方法,需要反射调用)

    private final static String ADD_ASSET_PATH = "addAssetPath";

    private String loadSkin(String skinFile) {
       ......
       //加载该皮肤资源
       AssetManager assetManager = AssetManager.class.newInstance();
       Method addAssetPathMethod = AssetManager.class.getMethod(ADD_ASSET_PATH, String.class);
       addAssetPathMethod.setAccessible(true);
       addAssetPathMethod.invoke(assetManager, skinFile);
        ...
    }

使用AssetManager 和 默认Resource配置,创建Resource对象。

Resources resources = new Resources(assetManager,
                    sysResource.getDisplayMetrics(), sysResource.getConfiguration());

2,替换系统Resource对象

以Activity为例, 在Activity的attachBaseContext(Context newBase)方法回调时,使用反射替换newBase中
Resource对象实例。

   private final static String CONTEXT_IMPL_CLASS_NAME = "android.app.ContextImpl";
    private final static String CONTEXT_IMPL_FIELD_NAME = "mResources";
    /**
     * @param contextImp 替换ContextImp对象中的Resource对象
     */
    public void createActivityResourceProxy(Context contextImp) {
        try {
            @SuppressLint("PrivateApi")
            Class<?> clazz = Class.forName(CONTEXT_IMPL_CLASS_NAME);
            Field field = clazz.getDeclaredField(CONTEXT_IMPL_FIELD_NAME);
            field.setAccessible(true);

            if (mResource == null) {
                mResource = new MResource(mSkinResource);
            }
            field.set(contextImp, mResource);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

MResource为第一步中创建Resource的包装类

public class MResource extends Resources {
    private SkinResource mSkinResource;

    public MResource(SkinResource skinResource) {
        super(...);
        mSkinResource = skinResource;
    }

    @Override
    @NonNull
    public CharSequence getText(int id) throws NotFoundException {
        Resources resource = mSkinResource.getRealResource(id);
        int realUsedResId = mSkinResource.getRealUsedResId(id);
        return resource.getText(realUsedResId);
    }
    ......

3,运行时动态映射

资源文件在编译打包后会生成一张资源表resourse.arsc, 将具体的资源文件与资源表中 ID一一对应。运行时,在由AssetManager根据资源表加载相应文件。但皮肤包中相同资源打包编译后,相同资源文件在资源表中 对应的ID却不一样。

resources.png

为解决这个问题,可以通过动态映射找出皮肤包中 对应的资源Id,原理是因为相同资源在不同的资源表中的Type和Name一样。

    private int findSkinResId(int resId) {
        //通过资源的 Name和Type,动态映射,找出皮肤包内 对应资源Id
        Resources sysResource = mContext.getResources();
        //资源名称  sample.jpg
        String resourceName = sysResource.getResourceEntryName(resId);
        //资源类型: drawable
        String resourceType = sysResource.getResourceTypeName(resId);
        int skinResId = mSkinResource.getIdentifier(resourceName, resourceType, mSkinPackageName);
        if (skinResId > 0) {
            //皮肤包内找到 对应资源
            return skinResId;
        }
        return FLAG_RESOURCE_NOT_FOUND;
    }

4,xml布局解析问题

通过以上步骤,在Activity中通过getResource().getDrawable(resId)方法 即可得到皮肤包中的Drawale,但是写在xml 布局文件中的资源却不能通过代理Resource加载。

写在布局中的资源,在xml解析后创建控件后,由TypedArray 解析加载资源。

    public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        this(context);

        final TypedArray a = context.obtainStyledAttributes(
                attrs, com.android.internal.R.styleable.View, defStyleAttr, defStyleRes);
      ......
   }

TypedArray 解析加载资源 方法,比如getDrawableForDensity(...) 使用的是 mResources.loadDrawable(value, value.resourceId, density, mTheme)方法,绕过了我们设置代理。因此加载是应用中的资源文件。

    @Nullable
    public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
        ......
        final TypedValue value = mValue;
        if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
            if (density > 0) {
                mResources.getValueForDensity(value.resourceId, density, value, true);
            }
            return mResources.loadDrawable(value, value.resourceId, density, mTheme);
        }
        return null;
    }

5,设置xml布局解析监听

为解决布局解析中资源加载的问题,我们可以使用自定义控件的方法, 使用Resource 加载资源 代替 TypeValue类。

通过为Activity添加布局解析监听, 全局替换自定义控件

  //为当前Activity设置 布局解析监听
   LayoutInflater inflater = LayoutInflater.from(activity);
   LayoutInflaterCompat.setFactory2(inflater, new SkinFactory(activity));
public class SkinFactory implements LayoutInflater.Factory2 {
     ......
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        View view = null;
        switch (name) {
            case "RelativeLayout":
                view = new SkinnableRelativeLayout(context, attrs);
                verifyNotNull(view, name);
                break;
            case "TextView":
                view = new SkinnableTextView(context, attrs);
                verifyNotNull(view, name);
                break;
        }
        return view;
    }

自定义控件 继承ISkinnableView接口,并实现updateSkin()方法,在换肤后,全局改变资源。

    @Override
    public void updateSkin() {
        SkinManager skinManager = SkinManager.getInstance();
        //设置字体颜色
        key = R.styleable.SkinnableTextView[R.styleable.SkinnableTextView_android_textColor];
        int textColorResourceId = attrsBean.getViewResource(key);
        if (textColorResourceId > 0) {
            ColorStateList color = skinManager.getColorStateList(textColorResourceId);
            setTextColor(color);
        }
    }

相对于AssetManager替换流 解决方案来说,Resource包装流 解决方案实现相对简单,但是却复杂很多,需要实现自定义控件等待。

四、AssetManager替换流 解决方案研究

相对于Resource包装流 替换系统Resource对象,AssetManager替换流的方案是 直接hook 系统的AssetManager对象。从而更优雅的解决加载资源的问题。

1, hook系统AssetMananger对象

AssetManager能解析并加载到资源的原因 在于 系统资源路径及 应用的资源路径 都添加到了AssetManager的Path当中。

我们可以直接将皮肤包中的资源文件 也添加到系统AssetManager的Path数组当中。

    public void addSkinPath(Context context, String skinPkgPath) throws Exception {
        PackageManager packageManager = context.getPackageManager();
        packageManager.getPackageArchiveInfo(skinPkgPath,
                PackageManager.GET_SIGNATURES | PackageManager.GET_META_DATA);

        AssetManager assetManager = AssetManager.class.newInstance();
        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);

        if(Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP){
            addAssetPath.invoke(assetManager, context.getApplicationInfo().publicSourceDir);
            addAssetPath.invoke(assetManager, skinPkgPath);

        }else {
            //5.0以上,需要將assets 资源文件单独添加
            File assetsFile = new File(skinPkgPath);
        //  File assetsFile = Utils.generateIndependentAsssetsForl(new File((skinPkgPath)));
            addAssetPath.invoke(assetManager, skinPkgPath);
            addAssetPath.invoke(assetManager,context.getApplicationInfo().publicSourceDir);
            addAssetPath.invoke(assetManager,assetsFile.getAbsolutePath());
        }
    }

在Activity 中attachBaseContext(Context newBase)方法中,将系统的context 替换成我们自己的context。

    public Context wrapperContext(Context context) {
        return new SkinContextWrapper(context);
    }

    public Context unWrapperContext(Context context) {
        if (context instanceof ContextWrapper) {
            return ((ContextWrapper) context).getBaseContext();
        }
        return context;
    }

2, 编译期静态对齐

与Resource包装流类型, 使用AssetManager替换方案 也存在资源表中文件不对应问题。但是由于后者是直接使用 AssetManager读取资源文件,因此不能使用动态映射方案,只能使用在程序编译时,修改Resources.arsc文件。 将皮肤包中资源文件 对应的id数值 修改与应用程序中 一致。

大概思路是可以通过定制 AAPT程序来实现,但是很遗憾,目前这只是一种思路。

五,总结

Resource替换流的解决方案,Github中已有一个开源项目:https://github.com/ximsfei/Android-skin-support

但是对于后一种方案,暂未找到相关实现。

本文相关代码见:https://github.com/deanxd/skinChange

上一篇下一篇

猜你喜欢

热点阅读