Android在App中直接展示String的Key

shoulder發表於2018-07-11

背景

我們的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


相關文章