美文网首页高级UIAndroid开发经验谈Android开发
Android 换肤那些事儿, Resource包装流 ?Ass

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

作者: 奶盖ww | 来源:发表于2019-07-30 20:46 被阅读11次

    嗨,你终于来啦 ~ 等你好久啦~ 喜欢的小伙伴欢迎关注,我会定期分享Android知识点及解析,还会不断更新的BATJ面试专题,欢迎大家前来探讨交流,如有好的文章也欢迎投稿。

    一、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);
        }
    

    内部函数调用如下:

    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对象。

    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却不一样。

    [图片上传中...(image-2eed34-1564489959774-0)]

    <figcaption></figcaption>

    为解决这个问题,可以通过动态映射找出皮肤包中 对应的资源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中已有一个开源项目:github.com/ximsfei/And…

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

    本文相关代码见:github.com/deanxd/skin…

    希望读到这的您能转发分享和关注一下我,以后还会分享Android知识点及解析,您的支持就是我最大的动力!!

    以下文章强烈推荐!!!

    相关文章

      网友评论

        本文标题:Android 换肤那些事儿, Resource包装流 ?Ass

        本文链接:https://www.haomeiwen.com/subject/ggyorctx.html