Android在App中直接展示String的Key
背景
我們的App Alibaba.com是一個國際B2B的電商平臺,支援18種語言,因為歷史原因每個語種的翻譯質量良莠不齊,在需要優化文案的時候,一般要經歷測試提出xx文案有問題->開發找key->PD改文案
這三步,其中開發找key的過程十分麻煩,基本等於翻程式碼,碰到不熟悉的邏輯都要糾結半天,給普普通通的優化文案的過程增加了無數工作量。
並且,直接在美杜莎平臺上通過value找key的方式也是不可取的,因為一個value有可能對應多個key,在這種情況下,只有翻程式碼才能找到正確的key。
經歷了人肉找key的痛苦之後,我就在思考,為什麼不做一個除錯工具出來,測試直接在app上找到有問題的文案的key,直接提給PD或者翻譯同學去修改,減少流程的複雜度,並且不再需要開發同學參與,皆大歡喜。
技術方案的總結
- 服務端:交個服務端去解決,客戶端直接展示
-
Android客戶端
- 使用LayoutInflater.Factory對view的生成進行hook
- 在子類重寫Activity#getResources(),使用裝飾者模式裝飾預設的resources。
- 使用AOP更方便的插入程式碼,避免release包中無關程式碼的上線
效果(放張圖感受一下)
方案的思考和形成和詳解
- app中展示的靜態文案大體分兩種,第一種是使用strings.xml靜態配置到app中,跟隨app打包;第二種是服務端通過介面下發的。後者的大體方案是由客戶端在介面中加入一個flag,服務端檢測有flag則傳遞key而非value,這種由服務端進行,不再贅述;身為客戶端開發,我們關注的主要是第一種的解決。
- 第一種又分為兩類,第一類是將文案以
android:text="@string/string_id"
的方式配置在layout.xml的view中,TextView在建立的時候通過attrs自己去拿的。第二類是開發者在java程式碼中通過textview.setText(int resId)
的方式去設定。閱讀原始碼,這兩者的實現非常不同:
- 通過xml方式配置的文案
/**
* class : TextView
*/
public TextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
//......
TypedArray a = theme.obtainStyledAttributes(attrs,
com.android.internal.R.styleable.TextViewAppearance, defStyleAttr, defStyleRes);
//......
case com.android.internal.R.styleable.TextView_text:
fromResourceId = true;
text = a.getText(attr); // 這裡通過TypedArray的例項獲取text
break;
// ......
}
- 通過textview.setText(int resId)設定文案
/**
* class : TextView
*/
public final void setText(@StringRes int resid) { //注意這裡的final
setText(getContext().getResources().getText(resid)); //這裡通過getContext().getResources()的方式
mTextFromResource = true;
}
對比以上兩種方式,我們可以嘗試去思考一些方案,比如對於第一種方式,我們可以嘗試使用繼承TextView並替換的方式來實現,在子類的構造方法中可以拿到attrs,進而拿到對應的id。而第二種設定文案的方式因為方法是final修飾,無法重寫,有些難以解決。
至於拿到id後,由int id轉成String idName的問題十分容易解決,通過getResources().getResourceEntryName(int resId)
這個方法即可。
簡單的思考到這裡,下面我們先討論第一種方式的技術方案。
獲取、並更改xml中的文案
繼承TextView可行,但是存量的程式碼改起來成本太大,不是首選方案,所以這裡不得不提到LayoutInflater中的一個神奇的方法setFactory/setFactory2
,這個方法可以設定一個Factory,在View被inflate之前,hook view inflate的邏輯,並可以做一些羞羞的事情。不過要注意的是,這個方法只適用於inflate的view,new TextView()這種是沒有辦法攔截到的。直接上程式碼。
/**
* class : BaseActivity
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
LayoutInflater inflater = LayoutInflater.from(this);
if (inflater.getFactory() == null) {
LayoutInflaterCompat.setFactory2(inflater, new FakedLayoutFactory());
}
super.onCreate(savedInstanceState);
......
}
/**
* class : FakedLayoutFactory
*/
public class FakedLayoutFactory implements LayoutInflater.Factory2, View.OnLongClickListener {
private static final String TAG = "FakedLayoutFactory";
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
LayoutInflater inflater = LayoutInflater.from(context);
// 注1開始
AppCompatActivity activity = null;
if (parent == null) {
if (context instanceof AppCompatActivity) {
activity = ((AppCompatActivity)context);
}
} else if (parent.getContext() instanceof AppCompatActivity) {
activity = (AppCompatActivity) parent.getContext();
}
if (activity == null) {
return null;
}
AppCompatDelegate delegate = activity.getDelegate();
int[] set = {
android.R.attr.text // idx 0
};
// 注1結束,這部分程式碼請看下面的詳細解析
// 不需要recycler,後面會在建立view時recycle的
@SuppressLint("Recycle") TypedArray a = context.obtainStyledAttributes(attrs, set);
View view = delegate.createView(parent, name, context, attrs);
if (view == null && name.indexOf(`.`) > 0) { //表明是自定義View
try {
view = inflater.createView(name, null, attrs);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
if (view instanceof TextView) {
int resourceId = a.getResourceId(0, 0);
if (resourceId != 0) {
String n = context.getResources().getResourceEntryName(resourceId);
((TextView) view).setText(n);
}
view.setOnLongClickListener(this);
}
return view;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return null;
}
/**
* 增加長摁展示完整的key的功能,畢竟有些key可能因為過長被截斷
*/
@Override
public boolean onLongClick(View v) {
if (v instanceof TextView) {
Toast.makeText(v.getContext(), ((TextView) v).getText(), Toast.LENGTH_LONG).show();
return true;
}
return false;
}
}
注1
不知道各位有沒有注意過,對於父類都是AppCompatActivity的應用,TextView、Button等原生控制元件在被infalte之後都變成了AppCompatTextView、AppCompatButton等support library中的控制元件。這即是由AppCompatActivity中設定的factory2實現的。程式碼如下,可以看到如果我們先設定了LayoutFactory的話,AppCompatActivity就不會再進行設定,但是我們又想保留其功能,不然整個app的展示會亂掉,所以需要在自己的factory中手動呼叫其內的方法。
/**
* class : AppCompatActivity
*/
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
final AppCompatDelegate delegate = getDelegate();
delegate.installViewFactory();
// ......
super.onCreate(savedInstanceState);
}
/**
* class : AppCompatDelegateImplV9
*/
@Override
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(mContext);
if (layoutInflater.getFactory() == null) {
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else {
// 如果之前已經設定過factory,那這裡就直接放棄了
if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImplV9)) {
Log.i(TAG, "The Activity`s LayoutInflater already has a Factory installed"
+ " so we can not install AppCompat`s");
}
}
}
/**
* From {@link LayoutInflater.Factory2}.
*/
@Override
public final View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
// First let the Activity`s Factory try and inflate the view
final View view = callActivityOnCreateView(parent, name, context, attrs); // 有興趣可以去看看這個方法
if (view != null) {
return view;
}
// If the Factory didn`t handle it, let our createView() method try
return createView(parent, name, context, attrs);
}
總結
結論顯而易見,只要我們在BaseActivity#onCreate()開始時設定我們自己實現的LayoutFactory,即可拿到id並以字串的方式展示出來。
獲取、並更改setText(int resId)的文案
通過在上面閱讀原始碼發現,TextView#setText(int resId)
這個方法有final修飾,且其為Android SDK的程式碼,我們無法觸及,所以根本無法hook這個method。那就只剩嘗試能不能hook Activity#getResoures()
這個方法了。
幸運的是,Activity#getResoures()
是public且沒有被final修飾的, 所以我們可以在BaseActivity中重寫該方法,使用一個Resouces的裝飾類來改變getResoures().getString(int resId)
的return值。
/**
* class : BaseActivity
*/
public Resources getResources() {
Resources resources = super.getResources();
return new FakeResourcesWrapper(resources); // 要做個記憶體快取節省效能
}
/**
* 裝飾者模式
*/
public class FakeResourcesWrapper extends Resources {
private Resources mResources;
private FakeResourcesWrapper(AssetManager assets, DisplayMetrics metrics, Configuration config) {
super(assets, metrics, config);
}
public FakeResourcesWrapper(Resources resources) {
super(resources.getAssets(), resources.getDisplayMetrics(), resources.getConfiguration());
mResources = resources;
}
// getText(int id); getString(int id); getString(int id, Object... formatArgs);getText(int id, CharSequence def)都需要被重寫,都返回resourceEntryName而非value
@NonNull
@Override
public CharSequence getText(int id) throws NotFoundException {
return super.getResourceEntryName(id);
}
//...... 其他所有的public方法都需要被重寫,使用被修飾的resouces的方法
@Override
public float getDimension(int id) throws NotFoundException {
return mResources.getDimension(id);
}
//......
}
使用AOP進行優化
上述方案已經可以完成我們的需求,不過需要一些前提條件,比如App中的所有Activity有個共同的父類(BaseActivity),並且需要侵入式的去寫程式碼,放到線上的話總會帶來風險。那麼有沒有什麼辦法可以做到無痕插入呢?
聰明的小朋友已經想到了,那就是AOP(Aspect Oriented Programming 面向切面程式設計),AOP的一般原理,是在編譯時根據一定的規則插入程式碼,來實現程式碼的完全解耦。同時因為現階段大部分Android App繼承的是AppCompatActivity,其在support library中,也會打包進apk,同時AppCompatActivity也重寫了getResources()方法,所以是可以被切入的,這樣的話一個app沒有BaseActivity也可以方便的插入程式碼。
我使用的是AspectJ作為我們app的AOP方案。
在接入之後,直接引入下面這個類,即可使程式碼切入
@Aspect
public class FakeAspect {
private WeakHashMap<Resources, Resources> cache = new WeakHashMap<>();
private FakedLayoutFactory mFactory = new FakedLayoutFactory();
public static boolean ENABLED = false;
// 在ParentBaseActivity.onCreate之前插入方法體中的程式碼
@Before("execution(* android.alibaba.support.base.activity.ParentBaseActivity.onCreate(..))")
public void onActivityBeforeCreated(JoinPoint point) {
if (ENABLED) {
LayoutInflater inflater = LayoutInflater.from((Context) point.getThis());
if (inflater.getFactory() == null) {
LayoutInflaterCompat.setFactory2(inflater, mFactory);
}
}
}
// pjp.proceed()是AppCompatActivity.getResources()的執行過程,可以更改其return值
@Around("execution(* android.support.v7.app.AppCompatActivity.getResources(..))")
public Resources onActivityGetResources(ProceedingJoinPoint pjp) throws Throwable {
if (ENABLED) {
Resources resources = (Resources) pjp.proceed();
Resources result = cache.get(resources);
if (result != null) {
return result;
}
result = new FakeResourcesWrapper(resources);
cache.put(resources, result);
return result;
} else {
return (Resources) pjp.proceed();
}
}
同時可以通過flavor的方式確保這個類不會打進release包中,這樣就安全、方便、乾淨的實現了程式碼插入。
打個廣告
阿里巴巴國際技術事業部招人啦!
招收Java、Android、iOS開發,要求3~5年開發經驗。
簡歷請投至郵箱shaode.lsd@alibaba-inc.com
相關文章
- hash_map中string為key的解決方法
- KeyPath在Swift中的妙用Swift
- Flutter系列二:探究Flutter App在Android宿主App中的整合FlutterAPPAndroid
- Android 無需申請key直接呼叫微信/QQ/微博分享Android
- 在Android App中整合Google登入AndroidAPPGo
- 直播app開發,Android ListView好友列表展示APPAndroidView
- { [key: string]: any } 是 TypeScript 中的一種型別註解TypeScript型別
- [Android] Folivora,在layout中直接建立drawableAndroid
- 在vue中展示自定義列名的甘特圖Vue
- 在xpath中text()和string(.)的區別
- android 獲取string.xml中的valueAndroidXML
- 在c#中把oracle表展示在datagridviewC#OracleView
- 在專案中如何直接使用hystrix?
- 使用openlayers在網頁中展示地理資訊網頁
- 分析String在記憶體中的表現記憶體
- 求助:service中get要求key型別必須為string嗎型別
- android中string.xml中%1$s、%1$d等的用法AndroidXML
- AutoMapper在MVC中的運用02-Decimal轉String、集合、子父類對映APPMVCDecimal
- Android中ExpandableListView,每次只展示一個分組AndroidView
- 直接通過瀏覽器開啟Android App 應用瀏覽器AndroidAPP
- ICONFONT在APP中的使用APP
- [android]android自動化測試十三之JavaMonkey跨APP操作AndroidJavaAPP
- a-numeric-string-as-array-key-in-PHPPHP
- Cordova在Android中的使用Android
- android中String與InputStream之間的相互轉換方式Android
- Check if String is HappyAPP
- iOS中的StringiOS
- java中的StringJava
- 淺析 App_KEY 的作用APP
- 在DELPHI2.0/3.0中直接操作埠 (轉)
- GIT SUBMODULE在Android中的使用GitAndroid
- 多媒體互動在展覽展示中的應用型別型別
- 【應用服務 App Service】App Service證書匯入,使用Key Vault中的證書APP
- webAppRootKey引數WebAPP
- Flutter中的Key(一)Flutter
- Flutter中的Key(二)Flutter
- Vue中key的作用Vue
- TavaScript中的keyof