背景
我们的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