AndroidHook机制——应用换肤

Android系统使用了ClassLoader机制来进行Activity等组件的加载;apk被安装之后,APK文件的代码以及资源会被系统存放在固定的目录(比如/data/app/package_name/1.apk)系统在进行类加载的时候,会自动去这一个或者几个特定的路径来寻找这个类;但是系统并不知道存在于插件中的Activity组件的信息,插件可以是任意位置,甚至是网络,系统无法提前预知,因此正常情况下系统无法加载我们插件中的类;因此也没有办法创建Activity的对象,更不用谈启动组件了。这个时候就需要使用动态加载技术了,关于Activity如何插件化,后面系列在说,本文讲了一个应用程序换肤的故事,虽然老套,但是对于理解动态加载技术很实用,读完之后你可以知道如何解决插件之中的资源加载问题。

关于类加载器,请看深入探讨 Java 类加载器

一、动态加载dex的技术

Android使用Dalvik虚拟机加载可执行程序,所以不能直接加载基于class的jar,而是需要将class转化为dex字节码,从而执行代码。优化后的字节码文件可以存在一个.jar中,只要其内部存放的是.dex即可使用。

我们现在要实现的一个需求是:如何调用一个非本应用的java程序,如下:

app 与loutillib两个模块没有任何的依耐关系,在Module App中,我们想调用Loutillib中的LogUitl输出一条log。LogUitl如下,so easy。

public class LogUitl {

    public static final String TAG="LogUitl";

    private void  printLog(){

        Log.e(TAG,"这是来自另外一个dex中的log");

    }

}

所以我们要在运行时把LogUitl动态加载到app这个进程中, Android支持动态加载的两种方式是:DexClassLoader和PathClassLoader,DexClassLoader可加载jar/apk/dex,且支持从SD卡加载;PathClassLoader只能加载已经安装在Android系统内APK文件( /data/app 目录下),其它位置的文件加载的时候都会出现 ClassNotFoundException。 因为 PathClassLoader 会去读取 /data/dalvik-cache 目录下的经过 Dalvik 优化过的 dex 文件,这个目录的 dex 文件是在安装 apk 包的时候由 Dalvik 生成的,没有安装的时候,自然没有生成那个文件。

这里我们用DexClassLoader来加载,LogUitl所生成的dex文件。首先用gradle打出LogUtil的jar包。

task makeJar(type:Copy){

    delete 'build/libs/log.jar'

    from('build/intermediates/bundles/release/')

    into('build/libs/')

    include('classes.jar')

    rename ('classes.jar', 'log.jar')

    exclude('test/','BuildConfig.class','R.class')

    exclude{it.name.startsWith('R$');}

}

makeJar.dependsOn(build)

注意,这个jar还不能被加载,这个是基于class的jar,Dalvik虚拟机加载的是dex字节码,所以需要将class转化为dex字节码。这个需要用到dx命令,这个可以在Android\sdk\build-tools\23.0.0中找到,把log.jar拷贝到这个目录下,执行

dx --dex --output=new_log.jar log.jar

在执行

adb  push  new_log.jar  sdcard/

把这个new_log放进SDCARD中,这样dex的准备工作就OK了。以下是用DexClassLoader动态加载的代码。

public class MainActivity extends Activity {

    @Override

    protected void attachBaseContext(Context newBase) {

        super.attachBaseContext(newBase);

    }

    @Override

    protected void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_main);

    }

    public void start(View view) {

        //dex解压释放后的目录 

        final File dexOutPutDir = getDir("dex", 0);

        //dex所在目录 

        final String dexPath = Environment.getExternalStorageDirectory().toString() + File.separator + "new_log.jar";

        //第一个参数:是dex压缩文件的路径

        //第二个参数:是dex解压缩后存放的目录

        //第三个参数:是C/C++依赖的本地库文件目录,可以为null

        //第四个参数:是上一级的类加载器 

        DexClassLoader classLoader=new DexClassLoader(dexPath,dexOutPutDir.getAbsolutePath(),null,getClassLoader());

        try {

            final Class<?> loadClazz = classLoader.loadClass("zhangwan.wj.com.logutillib.LogUitl");

            final Object o = loadClazz.newInstance();

            final Method printLogMethod = loadClazz.getDeclaredMethod("printLog");

            printLogMethod.setAccessible(true);

            printLogMethod.invoke(o);

        } catch (Exception e) {

            e.printStackTrace();

        }

    }

}

执行结果:

发现成功的调用了printLog方法。有上面的基础,现在实现一个难一点的,如何给应用程序换肤,这个难体现在资源加载上。通常各种各样的皮肤都是一个个的apk文件,当用户需要哪个皮肤,就下载到本地,然后动态加载,但是当宿主程序调起未安装的皮肤插件apk的时候,插件中以R开头的资源都不能被访问,程序会抛出异常,无法找到某某id所对应的资源。这是因为加载资源都是通过Resourse来实现的,Resource对象是由Context得到的,我们知道一个app的工程的资源文件都会隐射到R文件中,而这个R文件的包名则是这个应用的包名,所以一个包名一般对应一个Context。宿主与皮肤插件的包名是不一样的,所以宿主Context找不到皮肤插件的资源。

二、应用换肤

1、皮肤程序准备

sky.apk

children.apk

准备两个apk,sky.apk中有一张名字为skin_one的背景图,显示的是蓝色的天空;children.apk中也有一张名字为skin_one的背景图,显示的是一个小孩。将这两个apk都push到SD里面,两套皮肤准备完成。

2、资源加载问题怎么解决

通过分析系统资源加载了解到,系统是通过ContextImpl中的getAssets与getResources加载资源的

/**

    * Returns an AssetManager instance for the application's package.

    * <p>

    * <strong>Note:</strong> Implementations of this method should return

    * an AssetManager instance that is consistent with the Resources instance

    * returned by {@link #getResources()}. For example, they should share the

    * same {@link Configuration} object.

    *

    * @return an AssetManager instance for the application's package

    * @see #getResources()

    */

    public abstract AssetManager getAssets();

    /**

    * Returns a Resources instance for the application's package.

    * <p>

    * <strong>Note:</strong> Implementations of this method should return

    * a Resources instance that is consistent with the AssetManager instance

    * returned by {@link #getAssets()}. For example, they should share the

    * same {@link Configuration} object.

    *

    * @return a Resources instance for the application's package

    * @see #getAssets()

    */

    public abstract Resources getResources();

ContextImpl中,也就是说,只要实现这两个方法,就可以解决资源问题了。不饶弯子了,直接上代码,解释请移步Android动态加载技术三个关键问题详解。

/**

    * 获取AssetManager  用来加载插件资源

    * @param pFilePath  插件的路径

    * @return

    */

    private AssetManager createAssetManager(String pFilePath) {

        try {

            final AssetManager assetManager = AssetManager.class.newInstance();

            final Class<?> assetManagerClazz = Class.forName("android.content.res.AssetManager");

            final Method addAssetPathMethod = assetManagerClazz.getDeclaredMethod("addAssetPath", String.class);

            addAssetPathMethod.setAccessible(true);

            addAssetPathMethod.invoke(assetManager, pFilePath);

            return assetManager;

        } catch (Exception e) {

            e.printStackTrace();

        }

        return null;

    }

    //这个Resources就可以加载非宿主apk中的资源

    private Resources  createResources(String pFilePath){

        final AssetManager assetManager = createAssetManager(pFilePath);

        Resources superRes = this.getResources();

        return new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());

    }

3、动态加载皮肤apk

public class MainActivity extends Activity {

    private TextView  mSkinTv;

    private  boolean mChange=false;

    @Override

    protected void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_proxy);

        mSkinTv= (TextView) findViewById(R.id.skin_bg);

    }

    /**

    * 获取未安装apk的信息

    * @param context

    * @param pApkFilePath apk文件的path

    * @return

    */

    private String getUninstallApkPkgName(Context context, String pApkFilePath) {

        PackageManager pm = context.getPackageManager();

        PackageInfo pkgInfo = pm.getPackageArchiveInfo(pApkFilePath, PackageManager.GET_ACTIVITIES);

        if (pkgInfo != null) {

            ApplicationInfo appInfo = pkgInfo.applicationInfo;

            return appInfo.packageName;

        }

        return "";

    }

    public void switchSkin(View view) {

        String skinType="";

        if(!mChange){

            skinType= "sky.apk";

            mChange=true;

        }else {

            skinType= "children.apk";

            mChange=false;

        }

        final String path = Environment.getExternalStorageDirectory() + File.separator + skinType;

        final String pkgName = getUninstallApkPkgName(this, path);

        dynamicLoadApk(path,pkgName);

    }

    private  void dynamicLoadApk(String pApkFilePath,String pApkPacketName){

        File file=getDir("dex", Context.MODE_PRIVATE);

        //第一个参数:是dex压缩文件的路径

        //第二个参数:是dex解压缩后存放的目录

        //第三个参数:是C/C++依赖的本地库文件目录,可以为null

        //第四个参数:是上一级的类加载器

        DexClassLoader  classLoader=new DexClassLoader(pApkFilePath,file.getAbsolutePath(),null,getClassLoader());

        try {

            final Class<?> loadClazz = classLoader.loadClass(pApkPacketName + ".R$drawable");

          //插件中皮肤的名称是skin_one

            final Field skinOneField = loadClazz.getDeclaredField("skin_one");

            skinOneField.setAccessible(true);

            //反射获取skin_one的resousreId

            final int resousreId = (int) skinOneField.get(R.id.class);

            //可以加载插件资源的Resources

            final Resources resources = createResources(pApkFilePath);

            if (resources != null) {

                final Drawable drawable = resources.getDrawable(resousreId);

                mSkinTv.setBackground(drawable);

            }

        } catch (Exception e) {

            e.printStackTrace();

        }

    }

}

执行效果

到此换肤成功!


©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,837评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,551评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,417评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,448评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,524评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,554评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,569评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,316评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,766评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,077评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,240评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,912评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,560评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,176评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,425评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,114评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,114评论 2 352

推荐阅读更多精彩内容