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

2,136 阅读8分钟

Environment Switcher 是一个运用 Java 注解、APT、反射、混淆等原理来一键切换环境的工具。

Environment Switcher 已经正式发布一周了,本周随着 Environment Switcher 1.4 的发布,在这里为大家奉上 Environment Switcher 的原理解析。

如果你还不了解 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