Android 在App中直接展示String的Key

简介: 我们的App Alibaba.com是一个国际B2B的电商平台,支持18种语言,因为历史原因每个语种的翻译质量良莠不齐,在需要优化文案的时候,一般要经历`测试提出xx文案有问题->开发找key->PD改文案`这三步,其中开发找key的过程十分麻烦,基本等于翻代码,碰到不熟悉的逻辑都要纠结半天,给普普通通的优化文案的过程增加了无数工作量。

背景

我们的App Alibaba.com是一个国际B2B的电商平台,支持18种语言,因为历史原因每个语种的翻译质量良莠不齐,在需要优化文案的时候,一般要经历测试提出xx文案有问题->开发找key->PD改文案这三步,其中开发找key的过程十分麻烦,基本等于翻代码,碰到不熟悉的逻辑都要纠结半天,给普普通通的优化文案的过程增加了无数工作量。
并且,直接在美杜莎平台上通过value找key的方式也是不可取的,因为一个value有可能对应多个key,在这种情况下,只有翻代码才能找到正确的key。
经历了人肉找key的痛苦之后,我就在思考,为什么不做一个调试工具出来,测试直接在app上找到有问题的文案的key,直接提给PD或者翻译同学去修改,减少流程的复杂度,并且不再需要开发同学参与,皆大欢喜。

技术方案的总结

  • 服务端:交个服务端去解决,客户端直接展示
  • Android客户端

    1. 使用LayoutInflater.Factory对view的生成进行hook
    2. 在子类重写Activity#getResources(),使用装饰者模式装饰默认的resources。
    3. 使用AOP更方便的插入代码,避免release包中无关代码的上线

效果(放张图感受一下)

f1055c01e4901f8cafe84d73b958ab34.png

方案的思考和形成和详解

  1. app中展示的静态文案大体分两种,第一种是使用strings.xml静态配置到app中,跟随app打包;第二种是服务端通过接口下发的。后者的大体方案是由客户端在接口中加入一个flag,服务端检测有flag则传递key而非value,这种由服务端进行,不再赘述;身为客户端开发,我们关注的主要是第一种的解决。
  2. 第一种又分为两类,第一类是将文案以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

目录
相关文章
|
27天前
【Azure 应用服务】Web App Service 中的 应用程序配置(Application Setting) 怎么获取key vault中的值
【Azure 应用服务】Web App Service 中的 应用程序配置(Application Setting) 怎么获取key vault中的值
|
8天前
|
Java 数据库 Android开发
一个Android App最少有几个线程?实现多线程的方式有哪些?
本文介绍了Android多线程编程的重要性及其实现方法,涵盖了基本概念、常见线程类型(如主线程、工作线程)以及多种多线程实现方式(如`Thread`、`HandlerThread`、`Executors`、Kotlin协程等)。通过合理的多线程管理,可大幅提升应用性能和用户体验。
25 15
一个Android App最少有几个线程?实现多线程的方式有哪些?
|
10天前
|
Java 数据库 Android开发
一个Android App最少有几个线程?实现多线程的方式有哪些?
本文介绍了Android应用开发中的多线程编程,涵盖基本概念、常见实现方式及最佳实践。主要内容包括主线程与工作线程的作用、多线程的多种实现方法(如 `Thread`、`HandlerThread`、`Executors` 和 Kotlin 协程),以及如何避免内存泄漏和合理使用线程池。通过有效的多线程管理,可以显著提升应用性能和用户体验。
30 10
|
24天前
|
API Android开发
Android P 性能优化:创建APP进程白名单,杀死白名单之外的进程
本文介绍了在Android P系统中通过创建应用进程白名单并杀死白名单之外的进程来优化性能的方法,包括设置权限、获取运行中的APP列表、配置白名单以及在应用启动时杀死非白名单进程的代码实现。
42 1
|
27天前
|
IDE Java 开发工具
探索安卓开发之旅:打造你的第一款App
【8月更文挑战第24天】在这篇文章中,我们将一起踏上激动人心的安卓开发之旅。不论你是编程新手还是希望扩展技能的老手,本文将为你提供一份详尽指南,帮助你理解安卓开发的基础知识并实现你的第一个应用程序。从搭建开发环境到编写“Hello World”,每一步都将用浅显易懂的语言进行解释。那么,让我们开始吧!
|
29天前
|
存储 安全 网络安全
【应用服务 App Service】App Service证书导入,使用Key Vault中的证书
【应用服务 App Service】App Service证书导入,使用Key Vault中的证书
|
1月前
|
开发工具 Android开发
|
1月前
|
Java Android开发
解决Android编译报错:Unable to make field private final java.lang.String java.io.File.path accessible
解决Android编译报错:Unable to make field private final java.lang.String java.io.File.path accessible
95 1
|
1月前
|
Android开发
将AAB(Android App Bundle)转换为APK
将AAB(Android App Bundle)转换为APK
42 1
|
1月前
|
开发工具 Android开发
上架Google Play报错:For new apps, Android App Bundles must be signed with an RSA key.
上架Google Play报错:For new apps, Android App Bundles must be signed with an RSA key.
80 1