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

小邁發表於2018-09-02

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 為何如此強大?

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

這是因為它站在四大巨人的肩膀上,這四大巨人分別是 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:
複製程式碼

按照上面的對映關係,得到下圖結果:

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

為了證明我沒有在 mapping.txt 中遺漏 EnvironmentConfig 類的相關資訊,再貼張圖片:

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

當我藉助搜尋工具搜尋 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

相關文章