Environment Switcher 原理解析(注解、Apt、反射、混淆)

Environment Switcher 是一个在 Android 的开发和测试阶段,运用 Java 注解、APT、反射、混淆等原理来一键切换环境的工具。

如果你还不了解 Environment Switcher,建议先看一下这篇文章《一键切换应用环境工具(EnvironmentSwitcher)了解一下?

本文基于 Environment Switcher 1.4 分析。

Environment Switcher 回顾

用过 Environment Switcher 的人都知道,只需按应用中的模块配置环境,Environment Switcher 就会自动生成一系列方法。例如,下面的代码就是配置 Music 模块的环境:

public class EnvironmentConfig {
    @Module(alias = "音乐")
    private class Music {
        @Environment(url = "https://www.codexiaomai.top/api/", isRelease = true, alias = "正式")
        private String online;

        @Environment(url = "http://test.codexiaomai.top/api/", alias = "测试")
        private String test;
    }
}

只需要写这 10 行代码(包括括号和空行)编译之后,Environment Switcher 就会自动生成下面包含切换/获取环境添加/移除环境切换监听事件获取所有模块/环境 等功能在内的不到 100 行代码。

public final class EnvironmentSwitcher {
    
    private static final ArrayList ON_ENVIRONMENT_CHANGE_LISTENERS = new ArrayList<OnEnvironmentChangeListener>();

    private static final ArrayList MODULE_LIST = new ArrayList<ModuleBean>();

    public static final ModuleBean MODULE_MUSIC = new ModuleBean("Music", "音乐");

    private static EnvironmentBean sCurrentMusicEnvironment;

    public static final EnvironmentBean MUSIC_ONLINE_ENVIRONMENT = new EnvironmentBean("online", "https://www.codexiaomai.top/api/", "正式", MODULE_MUSIC);

    public static final EnvironmentBean MUSIC_TEST_ENVIRONMENT = new EnvironmentBean("test", "http://test.codexiaomai.top/api/", "测试", MODULE_MUSIC);

    private static final EnvironmentBean DEFAULT_MUSIC_ENVIRONMENT = MUSIC_ONLINE_ENVIRONMENT;

    static {
        ArrayList<EnvironmentBean> environments;

        MODULE_LIST.add(MODULE_MUSIC);
        environments = new ArrayList<>();
        MODULE_MUSIC.setEnvironments(environments);
        environments.add(MUSIC_ONLINE_ENVIRONMENT);
        environments.add(MUSIC_TEST_ENVIRONMENT);
    }

    public static void addOnEnvironmentChangeListener(OnEnvironmentChangeListener onEnvironmentChangeListener) {
        ON_ENVIRONMENT_CHANGE_LISTENERS.add(onEnvironmentChangeListener);
    }

    public static void removeOnEnvironmentChangeListener(OnEnvironmentChangeListener onEnvironmentChangeListener) {
        ON_ENVIRONMENT_CHANGE_LISTENERS.remove(onEnvironmentChangeListener);
    }

    public static void removeAllOnEnvironmentChangeListener() {
        ON_ENVIRONMENT_CHANGE_LISTENERS.clear();
    }

    private static void onEnvironmentChange(ModuleBean module, EnvironmentBean oldEnvironment, EnvironmentBean newEnvironment) {
        for (Object onEnvironmentChangeListener : ON_ENVIRONMENT_CHANGE_LISTENERS) {
            if (onEnvironmentChangeListener instanceof OnEnvironmentChangeListener) {
                ((OnEnvironmentChangeListener) onEnvironmentChangeListener).onEnvironmentChange(module, oldEnvironment, newEnvironment);
            }
        }
    }

    public static final String getMusicEnvironment(Context context, boolean isDebug) {
        return getMusicEnvironmentBean(context, isDebug).getUrl();
    }

    public static final EnvironmentBean getMusicEnvironmentBean(Context context, boolean isDebug) {
        if (!isDebug) {
            return DEFAULT_MUSIC_ENVIRONMENT;
        }
        if (sCurrentMusicEnvironment == null) {
            android.content.SharedPreferences sharedPreferences = context.getSharedPreferences(context.getPackageName() + ".environmentswitcher", android.content.Context.MODE_PRIVATE);
            String url = sharedPreferences.getString("musicEnvironmentUrl", DEFAULT_MUSIC_ENVIRONMENT.getUrl());
            String environmentName = sharedPreferences.getString("musicEnvironmentName", DEFAULT_MUSIC_ENVIRONMENT.getName());
            String appAlias = sharedPreferences.getString("musicEnvironmentAlias", DEFAULT_MUSIC_ENVIRONMENT.getAlias());
            for (EnvironmentBean environmentBean : MODULE_MUSIC.getEnvironments()) {
                if (android.text.TextUtils.equals(environmentBean.getUrl(), url)) {
                    sCurrentMusicEnvironment = environmentBean;
                    break;
                }
            }
        }
        return sCurrentMusicEnvironment;
    }

    public static final void setMusicEnvironment(Context context, EnvironmentBean environment) {
        context.getSharedPreferences(context.getPackageName() + ".environmentswitcher", android.content.Context.MODE_PRIVATE).edit()
                .putString("musicEnvironmentUrl", environment.getUrl())
                .putString("musicEnvironmentName", environment.getName())
                .putString("musicEnvironmentAlias", environment.getAlias())
                .apply();
        if (!environment.equals(sCurrentMusicEnvironment)) {
            onEnvironmentChange(MODULE_MUSIC, sCurrentMusicEnvironment, environment);
        }
        sCurrentMusicEnvironment = environment;
    }

    public static ArrayList getModuleList() {
        return MODULE_LIST;
    }
}

除了自动生成上面的代码外,Environment Switcher 还提供了展示和切换环境列表的 Activity 页面。Environment Switcher 为何如此强大?

这是因为它站在四大巨人的肩膀上,这四大巨人分别是 Java 注解 APT 反射混淆。相信大家对它们都有所耳闻,现在非常流行的 RetrofitButter Knife GreenDao 等开源库都使用了它们,这里就不做过多介绍了。

Environment Switcher 的组成与原理

打开 Environment Switcher 的项目目录,我们会看到 Environment Switcher 由base compiler compiler-release environmentswitchersample 五个模块构成。

  • base:包含所有的注解 @Moduel@Environment ,以及 Java Bean 类:ModuleBeanEnvironmentBean ,监听事件: OnEnvironmentChangeListener 和一个存储公共静态常量的类:Constants。其他几个模块都要依赖这个模块。
  • compiler:只包含一个类 EnvironmentSwitcherCompiler,在编译 Debug 版本时利用 APT 处理被注解标记的类和属性生成 EnvironmentSwitcher.java 文件。
  • compiler-release: 和 compiler 模块一样只包含一个类 EnvironmentSwitcherCompiler,在编译 Release 版本时利用 APT 处理被注解标记的类和属性生成 EnvironmentSwitcher.java 文件。
  • environmentswitcher:通过反射原理获取EnvironmentSwitcher.java 中生成的所有模块的环境,并提供列表展示以及切换环境功能的 Activity 页面。
  • sample:Environment Switcher 标准使用方法的示例工程。

为什么 Debug 版和 Release 版要用不同的注解处理工具

因为测试环境只在 Debug 和测试阶段使用,在 Release 版本中就只使用正式环境了,而如果 Release 版本中测试环境不隐藏就会打包到 apk 中,一旦被他人获取可能会带来不必要的麻烦或损失。

如何自动隐藏测试环境

我们先比较一下 compiler 和 compiler-release 生成的 EnvironmentSwitcher.java 文件主要有什么区别。其实主要区别就是生成的 EnvironmentBean 静态常量,具体区别如下:

  • Debug 版的 EnvironmentSwitcher.java
    public static final EnvironmentBean MUSIC_ONLINE_ENVIRONMENT = new  EnvironmentBean("online", "https://www.codexiaomai.top/api/", "正式", MODULE_MUSIC);
    
    public static final EnvironmentBean MUSIC_TEST_ENVIRONMENT = new EnvironmentBean("test", "http://test.codexiaomai.top/api/", "测试", MODULE_MUSIC);
    
  • Release 版的 EnvironmentSwitcher.java
    public static final EnvironmentBean MUSIC_ONLINE_ENVIRONMENT = new EnvironmentBean("online", "https://www.codexiaomai.top/api/", "正式", MODULE_MUSIC);
    
    public static final EnvironmentBean MUSIC_TEST_ENVIRONMENT = new EnvironmentBean("test", "", "测试", MODULE_MUSIC);
    

通过比较可以发现只有一个地方不同,那就是 Release 版中的非正式环境的具体地址为空字符串,这样就达到了隐藏测试环境具体地址的效果,进而解决了测试环境泄露的问题。

你可能又要说了,不要骗我啊,我在环境配置类 EnvironmentConfig.java 文件中还写了测试环境的地址呢,你看:

@Environment(url = "https://www.codexiaomai.top/api/", isRelease = true, alias = "正式")
private String online;

@Environment(url = "http://test.codexiaomai.top/api/", alias = "测试")
private String test;

先不要急,我慢慢来给大家解释。虽然通过 compiler-release 生成的类中把测试环境地址隐藏了,但在 EnvironmentConfig.java 中的确还活生生的包含测试地址的代码。那这个地方的测试环境怎么隐藏呢?

这就到了一直还没有出场的混淆工具上场了。

混淆助我一臂之力

先来简单回顾一下混淆的作用吧:
1、压缩(Shrink):检测并移除无用的类、字段、方法和属性
2、优化(Optimize):对字节码进行优化,移除无用指令
3、混淆(obfuscate):对类、方法、变量、属性进行重命名。
4、预检(preverify):对Java代码进行预检,以确保代码可以执行。

看到我用粗体标记的关键字了吧,Environment Switcher 就是利用 compiler-release 配合混淆工具的移除功能来实现隐藏测试环境的。

真的有这么神奇吗?是不是真的我们用事实说话。(这里以sample工程为例)

首先通过 Gradle 生成 Release 包,再对生成的 apk 文件进行反编译。下图是反编译后工程的目录结构:

反编译包结构

上面的图片中已经很清楚的展示了项目被混淆后的结构,至于为什么 EnvironmentSwitcher 包中所有子包和类都没有混淆,后面会介绍。

那么 com.xiaomai.demo 包中被混淆的类都分别对应于原工程中哪个文件呢?我们通过查看 EnvironmentSwitcher/sample/build/outputs/mapping/release 目录下找到 mapping.txt 文件,从中提取主要的信息如下:

com.xiaomai.demo.data.Api -> com.xiaomai.demo.a.a:
com.xiaomai.demo.data.GankResponse -> com.xiaomai.demo.a.b:
com.xiaomai.demo.data.MusicResponse -> com.xiaomai.demo.a.c:
com.xiaomai.demo.fragment.HomeFragment -> com.xiaomai.demo.b.a:
com.xiaomai.demo.fragment.MusicFragment -> com.xiaomai.demo.b.b:
com.xiaomai.demo.fragment.SettingsFragment -> com.xiaomai.demo.b.c:
com.xiaomai.demo.net.AppRetrofit -> com.xiaomai.demo.c.a:
com.xiaomai.demo.MainActivity -> com.xiaomai.demo.MainActivity:

com.xiaomai.environmentswitcher.Constants -> com.xiaomai.environmentswitcher.Constants:
com.xiaomai.environmentswitcher.EnvironmentSwitchActivity -> com.xiaomai.environmentswitcher.EnvironmentSwitchActivity:
com.xiaomai.environmentswitcher.EnvironmentSwitcher -> com.xiaomai.environmentswitcher.EnvironmentSwitcher:
com.xiaomai.environmentswitcher.R -> com.xiaomai.environmentswitcher.R:
com.xiaomai.environmentswitcher.annotation.Environment -> com.xiaomai.environmentswitcher.annotation.Environment:
com.xiaomai.environmentswitcher.annotation.Module -> com.xiaomai.environmentswitcher.annotation.Module:
com.xiaomai.environmentswitcher.bean.EnvironmentBean -> com.xiaomai.environmentswitcher.bean.EnvironmentBean:
com.xiaomai.environmentswitcher.bean.ModuleBean -> com.xiaomai.environmentswitcher.bean.ModuleBean:
com.xiaomai.environmentswitcher.listener.OnEnvironmentChangeListener -> com.xiaomai.environmentswitcher.listener.OnEnvironmentChangeListener:

按照上面的映射关系,得到下图结果:

为了证明我没有在 mapping.txt 中遗漏 EnvironmentConfig 类的相关信息,再贴张图片:

当我借助搜索工具搜索 EnvironmentConfig 关键字时,提示找不到该关键字,这再次证明了 EnvironmentConfig 被混淆工具移除了。

EnvironmentConfig 能被混淆工具移除的前提是不被其他任何类引用,这也是为什么建议将所有被 @Module@Environment 标注的类或属性用 private 修饰的原因。这样能在编写代码的阶段从根本上杜绝因测试环境被引用导致无法在混淆时被移除进而导致泄露。

为什么 EnvironmentSwitcher 中的类没被混淆

用过开源库或其他第三方非开源SDK的大家都知道,这些库或SDK有些会要求我配置混淆规则,否则会因混淆导致运行时异常。那么 EnvironmentSwitcer 为什么没有配置混淆规则,也没有被混淆呢?

这是因为 Environment Switcher 已经帮大家做了这一步,是不是很贴心?!Environment Switcher 设计的目标是:“在保证正常功能的前提下,让使用者少配置哪怕一行代码”。

那么 Environment Switcher 是怎么做到的呢?主要就是同过 Gradle 配置的。

  • build.gradle
    android {
        defaultConfig {
            ...
            consumerProguardFiles 'consumer-proguard-rules.pro'
        }
    }
    
  • consumer-proguard-rules.pro
    -dontwarn java.nio.**
    -dontwarn javax.annotation.**
    -dontwarn javax.lang.**
    -dontwarn javax.tools.**
    -dontwarn com.squareup.javapoet.**
    -keep class com.xiaomai.environmentswitcher.** { *; }
    

其实 Environment Switcher 除了帮大家做了混淆规则配置,还有很多地方。例如添加依赖配置方面:最初版本的 Environment Switcher 中 Activity 是继承于 AppCompatActivity,展示环境列表用的是 RecyclerView,这样就需要添加 support-v7 包和 recyclerview-v7 包,依赖方式如下:

implementation "com.android.support:appcompat-v7:$version"
implementation "com.android.support:recyclerview-v7:$version"

为什么这里不指定具体版本而要用 version 代替呢?

因为这个 version 是个 "TroubleMaker"。如果项目中依赖的 support-v7 包和 recyclerview-v7 包与Environment Switcher 中的版本不一致,Android Studio 在编译时会自动选择高版本的依赖,这样就可能产生兼容性错误,导致原本正常的项目因提示错误而编译失败。举个最简单的例子,在Api 26 中 Fragment 的 onCreateView方法的 LayoutInflater 参数是可空的,如下所示:

override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    return super.onCreateView(inflater, container, savedInstanceState)
}

而在 Api 27 中却强制不能为空,如下所示:

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    return super.onCreateView(inflater, container, savedInstanceState)
}

这就导致在编译时出现错误提示 'onCreateView' overrides nothing

其实这种错误是有方法解决的,具体方法如下:

implementation ("com.xiaomai.environmentswitcher:environmentswitcher:$version"){
    exclude group: 'com.android.support'
}

这样在引入 Environment Switcher 时就会移除 Environment Switcher 中的 support 包,但是总觉得这种方式不够优雅,违背了Environment Switcher 的设计目标。

于是我把 AppCampatActivity 替换为 Activity,RecyclerView 替换为 ListView。这两个类都是原生 Sdk 提供的,不需要引入任何依赖,又完美解决了问题。

为了方便开发者,Environment Switcher 还做了很多努力与尝试,在这里就不一一列举了。

Environment Switcher 除了可以用来做环境切换工具,还可以做其他的可配置开关,例如:打印日志的开关。(ps:这不是 Environment Switcher 设计时的目标功能,算是一个小彩蛋吧!)

@Module(alias = "日志")
private class Log {
    @Environment(url = "false", isRelease = true, alias = "关闭日志")
    private String closeLog;
    @Environment(url = "true", alias = "开启日志")
    private String openLog;
}

public void loge(Context context, String tag, String msg) {
    if (EnvironmentSwitcher.getLogEnvironmentBean(context, BuildConfig.DEBUG)
            .equals(EnvironmentSwitcher.LOG_OPENLOG_ENVIRONMENT)) {
        android.util.Log.e(tag, msg);
    }
}

当然这里只是举一个简单的例子,Environment Switcher 能做的远不止这些,更多功能欢迎大家动手尝试。

好了,关于Environment Switcher 的原理解析就到此为止吧,如果后续 Environment Switcher 更新,本文会同步更新。

划重点

嘿嘿,第一次做开源工具,如果喜欢 Environment Switcher 欢迎 Star

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

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,637评论 18 139
  • 院落清风起,幽幽明月来。 高歌思旧事,如梦影徘徊。 宝剑劳相赠,花叶随手摘。 且听声声雨,阶前长青苔。 【2017...
    d03e056874dc阅读 232评论 0 0
  • 在清晨时分,一缕阳光透射过薄薄的云雾。而此时,也有仙鹤飞舞在空中,小灵鸟们正在肆意的歌唱,大家的忙碌,就好像是...
    絮絮芬芳阅读 329评论 0 1
  • 在 phabricator 上新建了一个 Diffusion,Diffusion中配置了一个host模式的仓库,然...
    小发条阅读 1,675评论 0 0
  • 谢安筑埭称邵伯,荫下民声颂甘棠。 黎庶知恩心自赞,何须圣迹满街墙。 注:扬州市江都区邵伯镇。别称,“甘棠”或者是“...
    真老实人_425a阅读 905评论 0 5