Environment Switcher 是一個在 Android 的開發(fā)和測試階段,運用 Java 注解肪获、APT寝凌、反射、混淆等原理來一鍵切換環(huán)境的工具孝赫。
如果你還不了解 Environment Switcher较木,建議先看一下這篇文章《一鍵切換應(yīng)用環(huán)境工具(EnvironmentSwitcher)了解一下?》
本文基于 Environment Switcher 1.4 分析青柄。
Environment Switcher 回顧
用過 Environment Switcher 的人都知道伐债,只需按應(yīng)用中的模塊配置環(huán)境,Environment Switcher 就會自動生成一系列方法致开。例如峰锁,下面的代碼就是配置 Music 模塊的環(huán)境:
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 就會自動生成下面包含切換/獲取環(huán)境
双戳、添加/移除環(huán)境切換監(jiān)聽事件
虹蒋、獲取所有模塊/環(huán)境
等功能在內(nèi)的不到 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 還提供了展示和切換環(huán)境列表的 Activity 頁面千诬。Environment Switcher 為何如此強大?
這是因為它站在四大巨人的肩膀上膏斤,這四大巨人分別是 Java 注解
APT
反射
和 混淆
徐绑。相信大家對它們都有所耳聞,現(xiàn)在非常流行的 Retrofit
莫辨、Butter Knife
GreenDao
等開源庫都使用了它們傲茄,這里就不做過多介紹了。
Environment Switcher 的組成與原理
打開 Environment Switcher 的項目目錄沮榜,我們會看到 Environment Switcher 由base
compiler
compiler-release
environmentswitcher
和 sample
五個模塊構(gòu)成盘榨。
- base:包含所有的注解
@Moduel
和@Environment
,以及 Java Bean 類:ModuleBean
蟆融、EnvironmentBean
草巡,監(jiān)聽事件:OnEnvironmentChangeListener
和一個存儲公共靜態(tài)常量的類:Constants
。其他幾個模塊都要依賴這個模塊型酥。 - compiler:只包含一個類
EnvironmentSwitcherCompiler
山憨,在編譯 Debug 版本時利用 APT 處理被注解標記的類和屬性生成EnvironmentSwitcher.java
文件查乒。 - compiler-release: 和
compiler
模塊一樣只包含一個類EnvironmentSwitcherCompiler
,在編譯 Release 版本時利用 APT 處理被注解標記的類和屬性生成EnvironmentSwitcher.java
文件郁竟。 - environmentswitcher:通過反射原理獲取
EnvironmentSwitcher.java
中生成的所有模塊的環(huán)境玛迄,并提供列表展示以及切換環(huán)境功能的 Activity 頁面。 - sample:
Environment Switcher
標準使用方法的示例工程棚亩。
為什么 Debug 版和 Release 版要用不同的注解處理工具
因為測試環(huán)境只在 Debug 和測試階段使用蓖议,在 Release 版本中就只使用正式環(huán)境了,而如果 Release 版本中測試環(huán)境不隱藏就會打包到 apk 中讥蟆,一旦被他人獲取可能會帶來不必要的麻煩或損失勒虾。
如何自動隱藏測試環(huán)境
我們先比較一下 compiler 和 compiler-release 生成的 EnvironmentSwitcher.java
文件主要有什么區(qū)別。其實主要區(qū)別就是生成的 EnvironmentBean 靜態(tài)常量瘸彤,具體區(qū)別如下:
- 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);
通過比較可以發(fā)現(xiàn)只有一個地方不同从撼,那就是 Release 版中的非正式環(huán)境的具體地址為空字符串,這樣就達到了隱藏測試環(huán)境具體地址的效果钧栖,進而解決了測試環(huán)境泄露的問題。
你可能又要說了婆翔,不要騙我啊拯杠,我在環(huán)境配置類 EnvironmentConfig.java
文件中還寫了測試環(huán)境的地址呢,你看:
@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 生成的類中把測試環(huán)境地址隱藏了,但在 EnvironmentConfig.java 中的確還活生生的包含測試地址的代碼最蕾。那這個地方的測試環(huán)境怎么隱藏呢依溯?
這就到了一直還沒有出場的混淆工具上場了。
混淆助我一臂之力
先來簡單回顧一下混淆的作用吧:
1瘟则、壓縮(Shrink):檢測并移除無用的類黎炉、字段、方法和屬性醋拧。
2慷嗜、優(yōu)化(Optimize):對字節(jié)碼進行優(yōu)化,移除無用指令丹壕。
3庆械、混淆(obfuscate):對類、方法菌赖、變量缭乘、屬性進行重命名。
4琉用、預(yù)檢(preverify):對Java代碼進行預(yù)檢堕绩,以確保代碼可以執(zhí)行策幼。
看到我用粗體標記的關(guān)鍵字了吧,Environment Switcher 就是利用 compiler-release 配合混淆工具的移除功能來實現(xiàn)隱藏測試環(huán)境的逛尚。
真的有這么神奇嗎垄惧?是不是真的我們用事實說話。(這里以sample工程為例)
首先通過 Gradle 生成 Release 包绰寞,再對生成的 apk 文件進行反編譯到逊。下圖是反編譯后工程的目錄結(jié)構(gòu):
上面的圖片中已經(jīng)很清楚的展示了項目被混淆后的結(jié)構(gòu),至于為什么 EnvironmentSwitcher 包中所有子包和類都沒有混淆滤钱,后面會介紹觉壶。
那么 com.xiaomai.demo 包中被混淆的類都分別對應(yīng)于原工程中哪個文件呢?我們通過查看 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:
按照上面的映射關(guān)系铜靶,得到下圖結(jié)果:
為了證明我沒有在 mapping.txt 中遺漏 EnvironmentConfig 類的相關(guān)信息,再貼張圖片:
當我借助搜索工具搜索 EnvironmentConfig
關(guān)鍵字時他炊,提示找不到該關(guān)鍵字争剿,這再次證明了 EnvironmentConfig 被混淆工具移除了。
EnvironmentConfig 能被混淆工具移除的前提是不被其他任何類引用痊末,這也是為什么建議將所有被
@Module
和@Environment
標注的類或?qū)傩杂?private
修飾的原因蚕苇。這樣能在編寫代碼的階段從根本上杜絕因測試環(huán)境被引用導(dǎo)致無法在混淆時被移除進而導(dǎo)致泄露。
為什么 EnvironmentSwitcher 中的類沒被混淆
用過開源庫或其他第三方非開源SDK的大家都知道凿叠,這些庫或SDK有些會要求我配置混淆規(guī)則涩笤,否則會因混淆導(dǎo)致運行時異常。那么 EnvironmentSwitcer 為什么沒有配置混淆規(guī)則盒件,也沒有被混淆呢蹬碧?
這是因為 Environment Switcher 已經(jīng)幫大家做了這一步,是不是很貼心炒刁?恩沽!Environment Switcher 設(shè)計的目標是:“在保證正常功能的前提下,讓使用者少配置哪怕一行代碼”切心。
那么 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 除了幫大家做了混淆規(guī)則配置绽昏,還有很多地方协屡。例如添加依賴配置方面:最初版本的 Environment Switcher 中 Activity 是繼承于 AppCompatActivity,展示環(huán)境列表用的是 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 在編譯時會自動選擇高版本的依賴漫萄,這樣就可能產(chǎn)生兼容性錯誤,導(dǎo)致原本正常的項目因提示錯誤而編譯失敗盈匾。舉個最簡單的例子腾务,在Api 26 中 Fragment 的 onCreateView方法的 LayoutInflater 參數(shù)是可空的,如下所示:
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)
}
這就導(dǎo)致在編譯時出現(xiàn)錯誤提示 'onCreateView' overrides nothing
岩瘦。
其實這種錯誤是有方法解決的,具體方法如下:
implementation ("com.xiaomai.environmentswitcher:environmentswitcher:$version"){
exclude group: 'com.android.support'
}
這樣在引入 Environment Switcher 時就會移除 Environment Switcher 中的 support 包窿撬,但是總覺得這種方式不夠優(yōu)雅启昧,違背了Environment Switcher 的設(shè)計目標。
于是我把 AppCampatActivity 替換為 Activity劈伴,RecyclerView 替換為 ListView密末。這兩個類都是原生 Sdk 提供的,不需要引入任何依賴跛璧,又完美解決了問題严里。
為了方便開發(fā)者,Environment Switcher 還做了很多努力與嘗試追城,在這里就不一一列舉了田炭。
附
Environment Switcher 除了可以用來做環(huán)境切換工具,還可以做其他的可配置開關(guān)漓柑,例如:打印日志的開關(guān)。(ps:這不是 Environment Switcher 設(shè)計時的目標功能叨吮,算是一個小彩蛋吧A静肌)
@Module(alias = "日志")
private class Log {
@Environment(url = "false", isRelease = true, alias = "關(guān)閉日志")
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 能做的遠不止這些茶鉴,更多功能歡迎大家動手嘗試锋玲。
好了,關(guān)于Environment Switcher 的原理解析就到此為止吧涵叮,如果后續(xù) Environment Switcher 更新惭蹂,本文會同步更新。
劃重點
嘿嘿割粮,第一次做開源工具盾碗,如果喜歡 Environment Switcher 歡迎 Star 。