PreLoadHack

Android换肤技术 PreLoad Hack

参考 Android换肤技术总结

内部资源加载方案 大同小异,而且使用和实现缺陷非常多,实际使用价值不大.

  • 对于复杂的皮肤,需要太多的设置.

  • 对于简单的皮肤(类似白天/黑夜/关灯模式),有更简单的实现方式

主要来看动态加载方案

##resource替换
开源项目可参照Android-Skin-Loader

可以参考顶上的Blog链接

实现机制其实其实和遍历RootView的方案区别不大,这个是标记Skin enable后,遍历标记的view

遍历所有SkinItem,遍历SkinAttr,然后调用skinAtrr.apply(view)方法设置属性

这项目优点有2个:

  • 相比于遍历RootView的粗暴实现,这个实现划分层次更清晰

  • 将资源打包成apk,然后通过AssetManager加载

PackageManager mPm = context.getPackageManager();
PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
skinPackageName = mInfo.packageName;

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

Resources superRes = context.getResources();
Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());

实现机制ZYF写了2句,但是我感觉不是很对.


自己整理一下详细实现机制如下:

Android-Skin-Loader并没有覆盖application的getResource方法.

  • 使用时必须BaseActivity

  • onCreate的时候调用 getLayoutInflater().setFactory(mSkinInflaterFactory);

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    mSkinInflaterFactory = new SkinInflaterFactory();
    getLayoutInflater().setFactory(mSkinInflaterFactory);
}
  • 于是View被Inflater创建的时候会经过mSkinInflaterFactory的onCreateView方法

  • 在mSkinInflaterFactory的onCreateView方法中,获取所有相关的属性,保存到SkinItem数组中

  • 换肤的时候会调用BaseActivity的onThemeUpdate方法

  • onThemeUpdate方法中遍历所有SkinItem并调用apply方法修改参数

public void apply(View view) {        
    if(RES_TYPE_NAME_COLOR.equals(attrValueTypeName)){
        view.setBackgroundColor(SkinManager.getInstance().getColor(attrValueRefId));
    }else if(RES_TYPE_NAME_DRAWABLE.equals(attrValueTypeName)){
        Drawable bg = SkinManager.getInstance().getDrawable(attrValueRefId);
        view.setBackground(bg);
    }
}
  • 此时调用的getColor和getDrawable会通过AssetManager加载指定apk中的资源

整个流程中没有哪里经过Application,只是通过AssetManager加载了另一个apk中的Resource.

比遍历RootView好一点的就是它是通过LayoutInflater的Factory去检查每个View是否需要SkinUpdate功能,然后将需要的View保存下来,ThemeUpdate的时候只刷新这些View.
性能上应当比遍历RootView高效一些吧.

Hack Resources internally

引用自ZYF的Blog

黑科技方法,直接对Resources进行hack,Resources.java:

// Information about preloaded resources.  Note that they are not
// protected by a lock, because while preloading in zygote we are all
// single-threaded, and after that these are immutable.
private static final LongSparseArray<Drawable.ConstantState>[] sPreloadedDrawables;
private static final LongSparseArray<Drawable.ConstantState> sPreloadedColorDrawables
        = new LongSparseArray<Drawable.ConstantState>();
private static final LongSparseArray<ColorStateList> sPreloadedColorStateLists
        = new LongSparseArray<ColorStateList>();

直接对Resources里面的这三个LongSparseArray进行替换,由于apk运行时的资源都是从这三个数组里面加载的,所以只要采用interceptor模式:
自己实现一个LongSparseArray,并通过反射set回去,就能实现换肤,具体getDrawable等方法里是怎么取preload数组的,可以自己看Resources的源码。
等等,就这么简单?,NONO,少年你太天真了,怎么去加载xml,9patch的padding怎么更新,怎么打包/加载自定义的皮肤包,drawable的状态怎么刷新,等等。这些都是你需要考虑的,在存在插件的app中,还需要考虑是否会互相覆盖resource id的问题,进而需要修改apt,把resource id按位放在2个range。
手Q和独立版QQ空间使用的是这种方案,效果挺好。


这方案也没个具体说明,就一句 自己实现一个LongSparseArray ,真的是蛋碎.
不过有个提示也是好的.

首先反射一下该字段看看读出来什么东西

Resources resource = getApplicationContext().getResources();

try {
Field field =Resources.class.getDeclaredField("sPreloadedDrawables");
field.setAccessible(true);

LongSparseArray<Drawable.ConstantState>[]    sPreloadedDrawables = (LongSparseArray<Drawable.ConstantState>[] )field.get(resource);

for (LongSparseArray<Drawable.ConstantState> s:sPreloadedDrawables)
    for (int i = 0; i < s.size(); i++) {
        System.out.println(s.valueAt(i));
    }
} catch (IllegalAccessException e) {
    e.printStackTrace();
} catch (NoSuchFieldException e) {
    e.printStackTrace();
}

...
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.LayerDrawable$LayerState@8197fd4
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.LayerDrawable$LayerState@9c6357d
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.LayerDrawable$LayerState@95f0c72
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.StateListDrawable$StateListState@d78c540
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.StateListDrawable$StateListState@805ac79
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.BitmapDrawable$BitmapState@38d7dbe
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.StateListDrawable$StateListState@e829e1f
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.StateListDrawable$StateListState@4c4f56c
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.VectorDrawable$VectorDrawableState@82b9735
10-28 20:15:54.105 24502-24502/com.dk_exp.preloadhack I/System.out: android.graphics.drawable.VectorDrawable$VectorDrawableState@a9fb7ca
...

可以看到sPreloadedDrawables里持有大量的State对象,比如BitmapDrawable$BitmapState

作为BitmapDrawable的内部类,还是比较简单的,贴一下完整代码

final static class BitmapState extends ConstantState {
    final Paint mPaint;

    // Values loaded during inflation.
    int[] mThemeAttrs = null;
    Bitmap mBitmap = null;
    ColorStateList mTint = null;
    Mode mTintMode = DEFAULT_TINT_MODE;
    int mGravity = Gravity.FILL;
    float mBaseAlpha = 1.0f;
    Shader.TileMode mTileModeX = null;
    Shader.TileMode mTileModeY = null;
    int mTargetDensity = DisplayMetrics.DENSITY_DEFAULT;
    boolean mAutoMirrored = false;

    int mChangingConfigurations;
    boolean mRebuildShader;

    BitmapState(Bitmap bitmap) {
        mBitmap = bitmap;
        mPaint = new Paint(DEFAULT_PAINT_FLAGS);
    }

    BitmapState(BitmapState bitmapState) {
        mBitmap = bitmapState.mBitmap;
        mTint = bitmapState.mTint;
        mTintMode = bitmapState.mTintMode;
        mThemeAttrs = bitmapState.mThemeAttrs;
        mChangingConfigurations = bitmapState.mChangingConfigurations;
        mGravity = bitmapState.mGravity;
        mTileModeX = bitmapState.mTileModeX;
        mTileModeY = bitmapState.mTileModeY;
        mTargetDensity = bitmapState.mTargetDensity;
        mBaseAlpha = bitmapState.mBaseAlpha;
        mPaint = new Paint(bitmapState.mPaint);
        mRebuildShader = bitmapState.mRebuildShader;
        mAutoMirrored = bitmapState.mAutoMirrored;
    }

    @Override
    public boolean canApplyTheme() {
        return mThemeAttrs != null || mTint != null && mTint.canApplyTheme();
    }

    @Override
    public int addAtlasableBitmaps(Collection<Bitmap> atlasList) {
        if (isAtlasable(mBitmap) && atlasList.add(mBitmap)) {
            return mBitmap.getWidth() * mBitmap.getHeight();
        }
        return 0;
    }

    @Override
    public Drawable newDrawable() {
        return new BitmapDrawable(this, null);
    }

    @Override
    public Drawable newDrawable(Resources res) {
        return new BitmapDrawable(this, res);
    }

    @Override
    public int getChangingConfigurations() {
        return mChangingConfigurations
                | (mTint != null ? mTint.getChangingConfigurations() : 0);
    }
}

由于已经反射获得了sPreloadedDrawables ,那么想办法修改sPreloadedDrawables里的对象应当就可以修改 图片 资源了.

然而出现了多个问题

  • 由于BitmapState在类外无法访问,抽象类Drawable.ConstantState又没有提供修改的接口.

  • 稀疏数组的key并不是ResourceId

key = (((long) value.assetCookie) << 32) | value.data;

追踪一下调用栈,这个value对象来自一个native方法,暂时不方便获得assetCookie和data的计算方法

private native final int loadResourceValue(int ident, short density, TypedValue outValue,
        boolean resolve);

不过Resource本身提供getVaklue方法来给TypeValue填充数据

public void getValue(@AnyRes int id, TypedValue outValue, boolean resolveRefs)

那么我可以尝试直接通过TypeValue来读出preload中的数据

TypedValue value = new TypedValue();
resource.getValue(R.drawable.charming,value,true );

long  key = -1;
if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT
        && value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
    key = value.data;
} else {
    key = (((long) value.assetCookie) << 32) | value.data;
}
Drawable.ConstantState cs =sPreloadedDrawable.get(key);

然而这里遇到了一个问题,获取TypeValue和计算key都很正常

但是通过key获取ConstantState返回了null.

断点调试检查了LongSparseArray中的key数据,确实没有对应的值

调试的时候注意到这样一个问题

key = 8589934596

而LongSparseArray中的数据key为

4294967922
4294967923

程序员应当对这数字比较敏感

8589934596 = 0x200000004

4294967922 = 0x100000272

从上方key的计算逻辑中推导,可以看出是assertCookie不同

看起来我反射出的sPreloadedDrawables中并不一定包含我想要查找的资源


翻看Resource.loadDrawable的源码,发现drawabel也可能是从mDrawableCache中获取的

相关代码:

if (!mPreloading) {
    final Drawable cachedDrawable = caches.getInstance(key, theme);
    if (cachedDrawable != null) {
        return cachedDrawable;
    }
}

这个DrawableCache类本身只有包访问权限,反射代码还要写一堆,好在Debug模式下可以直接在resource里看到这个对象

Demo应用中drawable文件夹下只有2个资源,一张是我塞进去的测试图片,一张的ic_launch

检查了一下其持有的keys后,果然找到了8589934596.

于是下一步可以反射mDrawableCache并修改其中数据.

注意一个问题.这个 android.content.res.DrawableCache 类,只有包访问权限

不能使用Class.forName(“android.content.res.DrawableCache”)加载


这里我犯了个错误,我调试时使用的genymotion模拟器是5.0.1的

在API21版本中 drawableCache的实现是不同的

API21

private final ArrayMap<String, LongSparseArray<WeakReference<ConstantState>>> mDrawableCache =
         new ArrayMap<String, LongSparseArray<WeakReference<ConstantState>>>();

API23

private final DrawableCache mDrawableCache = new DrawableCache(this);

因为这个原因,在反射对象上浪费了一些时间,以后应当注意这个问题.
研究源码相关的东西时,一定要使用相同版本的设备/模拟器,不然完全是浪费时间.


换成6.0设备测试了一下,成功拿到了我想要的Drawable对象

代码如下

Resources resource = getApplicationContext().getResources();
 Object mdrawableCache = null;
 Field field = null;
 try {
     field = Resources.class.getDeclaredField("mDrawableCache");
 } catch (NoSuchFieldException e) {
     e.printStackTrace();
 }
 field.setAccessible(true);
 try {
     mdrawableCache = field.get(resource);
 } catch (IllegalAccessException e) {
     e.printStackTrace();
 }

 TypedValue value = new TypedValue();
 resource.getValue(R.drawable.charming,value,true );

 long  key = -1;
 if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT
         && value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
     key = value.data;
 } else {
     key = (((long) value.assetCookie) << 32) | value.data;
 }

 Method method = null;
 try {
     Class  c = mdrawableCache.getClass();
     method = c.getDeclaredMethod("getInstance",long.class,Resources.Theme.class);
 } catch (NoSuchMethodException e) {
     e.printStackTrace();
 }
 Drawable drawable = null;
 try {
     drawable = (Drawable) method.invoke(mdrawableCache, key, null);
 } catch (IllegalAccessException e) {
     e.printStackTrace();
 } catch (InvocationTargetException e) {
     e.printStackTrace();
 }

下面考虑替换该Drawable并刷新View,参考ThemedResourceCache源码,猜测可以调用put方法把修改后的Drawable对象塞进去.

由于ThemeResourceCache持有的实际上还是Drawable.ConstantState对象,Drawable对象由其newDrawable()方法获取,所以应当构建BitmapState对象

这里依然非常蛋疼,BitmapState是BitmapDrawable的静态内部类,default,只有包访问权限.

无论是构造对象,调用方法,修改参数,都需要通过反射,感觉真的是非常非常麻烦.


从研究过程中看,行为依赖Resource本身DrawableCache和Preload的实现,而且5.0和6.0其实现逻辑又不同.

通过反射hack cache来做资源替换看起来并不是一个稳妥的方案.