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

目录
相关文章
|
1月前
|
XML Java 数据库
安卓项目:app注册/登录界面设计
本文介绍了如何设计一个Android应用的注册/登录界面,包括布局文件的创建、登录和注册逻辑的实现,以及运行效果的展示。
148 0
安卓项目:app注册/登录界面设计
|
3月前
【Azure 应用服务】Web App Service 中的 应用程序配置(Application Setting) 怎么获取key vault中的值
【Azure 应用服务】Web App Service 中的 应用程序配置(Application Setting) 怎么获取key vault中的值
|
2月前
|
Java 数据库 Android开发
一个Android App最少有几个线程?实现多线程的方式有哪些?
本文介绍了Android多线程编程的重要性及其实现方法,涵盖了基本概念、常见线程类型(如主线程、工作线程)以及多种多线程实现方式(如`Thread`、`HandlerThread`、`Executors`、Kotlin协程等)。通过合理的多线程管理,可大幅提升应用性能和用户体验。
131 15
一个Android App最少有几个线程?实现多线程的方式有哪些?
|
2月前
|
存储 开发工具 Android开发
使用.NET MAUI开发第一个安卓APP
【9月更文挑战第24天】使用.NET MAUI开发首个安卓APP需完成以下步骤:首先,安装Visual Studio 2022并勾选“.NET Multi-platform App UI development”工作负载;接着,安装Android SDK。然后,创建新项目时选择“.NET Multi-platform App (MAUI)”模板,并仅针对Android平台进行配置。了解项目结构,包括`.csproj`配置文件、`Properties`配置文件夹、平台特定代码及共享代码等。
174 2
|
2月前
|
XML Android开发 数据格式
🌐Android国际化与本地化全攻略!让你的App走遍全球无障碍!🌍
在全球化背景下,实现Android应用的国际化与本地化至关重要。本文以一款旅游指南App为例,详细介绍如何通过资源文件拆分与命名、适配布局与方向、处理日期时间及货币格式、考虑文化习俗等步骤,完成多语言支持和本地化调整。通过邀请用户测试并收集反馈,确保应用能无缝融入不同市场,提升用户体验与满意度。
104 3
|
2月前
|
Java 数据库 Android开发
一个Android App最少有几个线程?实现多线程的方式有哪些?
本文介绍了Android应用开发中的多线程编程,涵盖基本概念、常见实现方式及最佳实践。主要内容包括主线程与工作线程的作用、多线程的多种实现方法(如 `Thread`、`HandlerThread`、`Executors` 和 Kotlin 协程),以及如何避免内存泄漏和合理使用线程池。通过有效的多线程管理,可以显著提升应用性能和用户体验。
73 10
|
1月前
|
安全 网络安全 Android开发
深度解析:利用Universal Links与Android App Links实现无缝网页至应用跳转的安全考量
【10月更文挑战第2天】在移动互联网时代,用户经常需要从网页无缝跳转到移动应用中。这种跳转不仅需要提供流畅的用户体验,还要确保安全性。本文将深入探讨如何利用Universal Links(仅限于iOS)和Android App Links技术实现这一目标,并分析其安全性。
240 0
|
2月前
|
XML 数据库 Android开发
10分钟手把手教你用Android手撸一个简易的个人记账App
该文章提供了使用Android Studio从零开始创建一个简单的个人记账应用的详细步骤,包括项目搭建、界面设计、数据库处理及各功能模块的实现方法。
|
3月前
|
API Android开发
Android P 性能优化:创建APP进程白名单,杀死白名单之外的进程
本文介绍了在Android P系统中通过创建应用进程白名单并杀死白名单之外的进程来优化性能的方法,包括设置权限、获取运行中的APP列表、配置白名单以及在应用启动时杀死非白名单进程的代码实现。
64 1
|
3月前
|
IDE Java 开发工具
探索安卓开发之旅:打造你的第一款App
【8月更文挑战第24天】在这篇文章中,我们将一起踏上激动人心的安卓开发之旅。不论你是编程新手还是希望扩展技能的老手,本文将为你提供一份详尽指南,帮助你理解安卓开发的基础知识并实现你的第一个应用程序。从搭建开发环境到编写“Hello World”,每一步都将用浅显易懂的语言进行解释。那么,让我们开始吧!
下一篇
无影云桌面