Android開發&國際化多語言處理

寫在前面

Android中的資源文件,在使用時都是根據系統語言來處理的,如果當前環境爲英文,則在需要使用字符串等資源時,會自動從values-en類目錄中提取,這也是應用國際化的基礎

一般的軟件中,不會在應用內進行語言環境的切換,默認在系統整體語言發生改變時,界面會進行重啓,當然,也可以人爲進行攔截操作。

不過由於api一直在變更,針對本地語言的變更處理方式也有了些不同,同樣,如果想要在應用內自定義一套語言切換功能,也變得比較的繁瑣。

最後的話,也會就ActivityThread類,來簡單解讀一下,在系統語言環境發生變化時,代碼的執行邏輯

一、設定語言切換界面

在app內單獨定義語言切換的話,至少得先有切換的功能,支持的語言應當至少有三種:

  1. 中文環境
  2. 英文環境
  3. 跟隨系統

在選定了中文環境或者英文環境時,系統語言的切換對該應用本身不起作用,在選定了跟隨系統時,則會根據當前系統語言,來動態的調整顯示情況。

界面類似這樣:

在這裏插入圖片描述

activity 源碼如下:

@Route(path = ARouterConst.Activity_SwitchLocaleActivity)
@InjectActivityTitle(titleRes = R.string.label_switch_locale)
@DisableAPTProcess(disables = [APTPlugins.BUTTERKNIF, APTPlugins.AROUTER, APTPlugins.DAGGER])
class SwitchLocaleActivity : BaseActivity<BasePresenter<SoftSettingActivity>>(), LineMenuListener {
    /**
     * 佈局文件控件
     *
     * 默認
     * 中文
     * 臺灣
     * 香港
     * 英文
     */
    private var lmvs = arrayOfNulls<LineMenuView>(5)

    override fun getContentOrViewId(): Int {
        verticalLayout {
            //toolbar
            include<AppBarLayout>(R.layout.layout_top_bar)

            //內容區域
            scrollView {
                overScrollMode = View.OVER_SCROLL_ALWAYS
                isVerticalScrollBarEnabled = false

                verticalLayout {
                    //系統存儲的值
                    val locale = DefaultPreferenceUtil.getInstance().localeLanguageSwitch
                    val menus = getStringArray(R.array.array_locale_language)

                    //初始化界面
                    for (i in lmvs.indices) {
                        //lmv
                        lmvs[i] = lmv_select(menuText = menus[i]) {
                            rightSelect = i == locale
                        }.lparams(width = matchParent) {
                            if (i == 0) {
                                topMargin = dimen(R.dimen.view_padding_margin_10dp)
                            }
                        }

                        //分隔符divider
                        if (i < lmvs.size - 1) {
                            dv_line().lparams(width = matchParent)
                        }
                    }
                }.lparams(width = matchParent).applyRecursively {
                    if (it is LineMenuView || it is DividerView) {
                        it.horizontalPadding = dimen(R.dimen.view_padding_margin_16dp)
                        it.backgroundColorResource = R.color.main_color_white
                    }
                }
            }.lparams(matchParent, matchParent)
        }

        return 0
    }

    /**
     * @param v 被點擊到的v;此時應該是該view自身:LineMenuView
     */
    override fun performSelf(v: LineMenuView) {
        if (!v.rightSelect) {
            (v.getTag(LMVConfigs.TAG_POSITION) as Int).let { position ->
                ProgressDialog.getInstance(this@SwitchLocaleActivity).show()
                DefaultPreferenceUtil.getInstance().localeLanguageSwitch = position
                LOCALE_LANGUAGE_TYPES[position]?.also {
                    BaseApplication.app.setLocale(it)
                } ?: BaseApplication.app.setLocale(BaseApplication.app.systemLocale)
                ProgressDialog.getInstance(this@SwitchLocaleActivity).dismiss()

                //恢復上個狀態
                for (i in lmvs.indices) {
                    lmvs[i]?.rightSelect = i == position
                }

                //刷新界面佈局
                onConfigurationChanged(resources.configuration)
            }
        }
    }

    /**
     * 刷新當前界面:主要是標題和默認
     */
    override fun onConfigurationChanged(newConfig: Configuration?) {
        super.onConfigurationChanged(newConfig)
        lmvs[0]?.menuText = getStringArray(R.array.array_locale_language)[0]
        title = Unit.getString(R.string.label_switch_locale)
        setResult(Activity.RESULT_OK)
    }
}

這裏使用的是kotlin語言

類上的註冊主要是說明activity標題等信息;

getContentOrViewId 方法是以anko的形式注入了界面佈局

performSelf方法用來控件LineMenuView的點擊事件

onConfigurationChanged 方法處理當系統的語言改變時,activity自定義的處理方法

這裏使用的控件LineMenuView是一種敏捷開發的菜單庫,在實現一些行式佈局時比較方便,github地址爲:LineMenuView

重要的是,要在Manifest文件中,聲明該活動需要自定義語言環境改變的處理方式(這裏是連同橫豎屏切換一同進行了攔截):

<activity
    android:name=".SwitchLocaleActivity"
    android:configChanges="keyboard|screenSize|orientation|locale|layoutDirection"/>

當然,還有一個使用到的佈局文件沒有貼上源碼,不過查看效果圖,就可以明白未說明部分的含義了

二、選擇全局保存當前語言的方式

可以通過多種方式保存選擇的語言環境,這裏使用 preferences 處理:

public class DefaultPreferenceUtil {
    //...
    
    /**
     * 切換語言環境
     * <p>
     * 0:未設置 或 跟隨系統變化
     * 1:簡體中文-中國大陸
     * 2:繁體中文-中國臺灣
     * 3:繁體中文-中國香港
     * 4:英語-全體-English
     */
    @NotNull
    public int getLocaleLanguageSwitch() {
        return preferences.getInt(LOCALE_LANGUAGE_SWITCH, 0);
    }
    
    public boolean setLocaleLanguageSwitch(@NotNull @IntRange(from = 0, to = 4) int locale) {
        return edit.putInt(LOCALE_LANGUAGE_SWITCH, locale).commit();
    }
    
    //...
}

int 來保存語言設置,默認爲 0,表示跟隨系統。

就目前規定來說,不管在什麼情況下,getLocaleLanguageSwitch 方法獲取的值都只能處於 0 - 4 之間,同時還也對應着上面效果圖中的五個LineMenuView控件

三、在Application中進行攔截處理

需要注意的是,如果不進行任何處理的話,應用在啓動時,讀取到的語言環境將是系統設置的那個,因此我們需要在Application啓動時就做出處理,根據我們之前設定的環境進行更改

這個過程需要分兩步進行:

1、獲取配置對應的Locale對象

根據 preferences 中存儲的值,我們可以獲取對應的 Locale 對象

val LOCALE_LANGUAGE_TYPES = arrayOf(
        null,
        Locale.SIMPLIFIED_CHINESE,
        Locale.TRADITIONAL_CHINESE,
        Locale("zh", "HK"),
        Locale.ENGLISH
)

如果應用之前沒有設置過語言環境,或者說設置的語言環境爲跟隨系統,則此處返回 null,否則,返回語言地區對應的Locale

2、在Application中進行更替

ApplicationonCreate方法中,調用以下代碼

//設置默認環境
Locale target = Const.INSTANCE.getLOCALE_LANGUAGE_TYPES()[DefaultPreferenceUtil.getInstance().getLocaleLanguageSwitch()];
if (target != null) {
    setLocale(target);
}

這裏獲取的 target 就是第一步中的Locale對象,如果爲null的話,就不修改原有邏輯,否則,更替目前應用使用的 Locale

setLocale 方法比較複雜,因此將其單獨提出來:

/**
 * 設置語言對象
 */
@SuppressLint("ObsoleteSdkInt")
public void setLocale(@NotNull Locale target) {
    // 獲得res資源對象
    Resources resources = getResources();

    // 獲得設置對象
    Configuration config = resources.getConfiguration();

    // 獲得屏幕參數:主要是分辨率,像素等。
    DisplayMetrics dm = resources.getDisplayMetrics();

    // 語言
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
        config.setLocale(target);
    } else {
        config.locale = target;
    }
    resources.updateConfiguration(config, dm);
}

這樣,就完成了基本的語言切換功能;

不過僅僅如此的話,如果應用正在運行過程中,系統語言環境發生了改變,那麼應用將不能夠正確的進行處理;

比如,如果應用已經鎖定了環境爲英文,在系統語言環境切換爲中文時,回到之前的界面,界面將自動重啓,然後以中文的樣式進行顯示。

事實上,在系統環境發生變化時,我們需要根據應用中保存的值來判斷,如果是跟隨系統,那麼將不進行任何處理,如果是其他情況,則會判斷切換的語言是否與當前設置的語言相同,不相同則進行一次修改,相同則不進行任何處理。

具體邏輯如下:

/**
 * application監聽到環境發生變化時,需要 根據情況來判斷是否切換語言環境
 * <p>
 * 1.如果當前應用設置了語言環境(非跟隨系統變化) ,則不會通知應用切換語言(使用自身默認的語言)
 * 2.如果當前應用設置跟隨系統變化,或者未設置默認語言環境(兩者可做統一處理),則判斷當前與系統語言是否相同,不同則進行切換(默認不操作)
 */
@Override
public synchronized void onConfigurationChanged(Configuration newConfig) {
    super.onConfigurationChanged(newConfig);

    //系統切換的Locale值
    systemLocale = PlusFunsPluginsKt.getApplicationLocale(null, newConfig);

    Logger.v(getClass().getSimpleName() + "監聽到:環境發生變化:%s", newConfig.toString());

    int current = DefaultPreferenceUtil.getInstance().getLocaleLanguageSwitch();

    switch (current) {
        case 0:
        case 1:
        case 2:
        case 3:
        case 4:
            Locale target = Const.INSTANCE.getLOCALE_LANGUAGE_TYPES()[current];
            if (target != null && !target.equals(systemLocale)) {
                setLocale(target);
            }
            break;
        default:
            showToast("error language!!!");
            System.exit(0);
    }
}

getApplicationLocale方法很簡單,只是獲取當前應用的,或者傳入config對應的Locale值:

/**
 * get Application Locale
 *
 * 如果傳入config則獲取當前config的locale值,如果未傳入,則默認返回當前應用的locale(非系統)
 */
inline fun <T> T.getApplicationLocale(config: Configuration? = null): Locale {
    return (config ?: BaseApplication.app.resources.configuration).run {
        if (Build.VERSION.SDK_INT < 24) locale else locales.get(0)
    }
}

如此一來,哪怕系統環境發生了改變,app也能對應的做出處理:是保持原有,或者跟隨系統變化

四、 處理Activity環境,兼容8.0系統

經過以上三個步驟,Application對應的Context對象在獲取資源時,沒有任何問題,但Activity中的Context對象,其實語言環境並沒有切換過來

我們通過BaseApplication.app.getResources().getString()方法獲取到 的 值 和通過 activity.getResources().getString()方法獲取到的值可能會不同;因爲他們分別對應着不同的Context上下文對象。

因此只是在環境切換後重啓Activity是不起作用的(安卓碎片化比較嚴重,api各個版本都有差別),還需要在BaseActivity(活動的基類)中重載Context的綁定方式:

@Override
protected void attachBaseContext(Context newBase) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // 8.0需要使用createConfigurationContext處理
        newBase = updateResources(newBase);
    }

    super.attachBaseContext(newBase);
}

@TargetApi(Build.VERSION_CODES.N)
public Context updateResources(Context context) {
    Locale locale = PlusFunsPluginsKt.getApplicationLocale(null, null);
    Configuration configuration = context.getResources().getConfiguration();
    configuration.setLocale(locale);
    configuration.setLocales(new LocaleList(locale));
    return context.createConfigurationContext(configuration);
}

這樣,在activity重啓之後,界面語言環境才能顯示正常;

然後我們回頭再來看一下第一部分列出的Activity的部分源碼:

/**
 * 刷新當前界面:主要是標題和默認
 */
override fun onConfigurationChanged(newConfig: Configuration?) {
    super.onConfigurationChanged(newConfig)
    lmvs[0]?.menuText = getStringArray(R.array.array_locale_language)[0]
    title = Unit.getString(R.string.label_switch_locale)
    setResult(Activity.RESULT_OK)
}

這裏在回調中,在獲取字符串值時,並沒有直接使用getString(R.string.label_switch_locale),因爲這樣的話,將使用Activity的Context對象來獲取資源,此時,Activity的Context對象對應的語言環境根本沒有任何變化,因此界面會出現錯誤;

注:這裏Unit.getString(R.string.label_switch_locale)是利用kotlin動態添加的方法,實際上是利用BaseApplication的Context對象進行取值:

/**
 * 獲取string
 */
@Suppress("NOTHING_TO_INLINE")
inline fun <T> T.getString(@StringRes res: Int, vararg formatArgs: Any?): String {
    return BaseApplication.app.getString(res, *formatArgs)
}

五、攔截/不攔截 系統語言改變事件

如果需要自定義系統語言事件的處理方法,則需要向前面說明的那樣,首先在manifest文件中進行configChanges聲明,然後在Activity中重載onConfigurationChanged方法進行邏輯處理。

如果是在應用內切換了語言環境,那麼一般來說,需要手動的進行重啓,像這樣:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    //重啓整個應用
    if (requestCode == Const.REQUEST_CODE_ONE && resultCode == Activity.RESULT_OK) {
       recreate()
    }
}

這裏假定的情況是:該代碼所在的Activity啓動了一個可以切換應用語言環境的 Activity,如果語言環境被切換,那麼在界面返回時,代碼所在界面要進行刷新處理,否則界面內容不會發生改變。

這裏還有一點需要注意,有些包含Fragment的Activity,是不能直接調用recreate方法的,否則會導致應用崩潰,因此需要採用其他方法來進行處理(可以重新啓動一個新的activity,然後結束老的活動,或者其他方法)

六、最後

在最後部分,簡單來看一下語言環境改變時,系統方法的調用順序;

首先,需要找到ActivityThread類,該類是一切的起點,然後找到這個:

private class ApplicationThread extends IApplicationThread.Stub{
    //...
    
    public void scheduleApplicationInfoChanged(ApplicationInfo ai) {
        sendMessage(H.APPLICATION_INFO_CHANGED, ai);
    }
    
   @Override
    public void scheduleActivityConfigurationChanged(
            IBinder token, Configuration overrideConfig) {
        sendMessage(H.ACTIVITY_CONFIGURATION_CHANGED,
                new ActivityConfigChangeData(token, overrideConfig));
    }
            
    //...
}

其實看到*Stub的樣式,就應該明白,則是個AIDL調用,具體誰調用的,我們不去深究,以上兩個方法已經指明:當Config有變化時,將發送Message消息 : ACTIVITY_CONFIGURATION_CHANGEDAPPLICATION_INFO_CHANGED

APPLICATION_INFO_CHANGED單從名字上就可以看出,是讓Application執行相應邏輯

查看這段代碼:

private class H extends Handler {
    public void handleMessage(Message msg) {
        if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
        switch (msg.what) {
             //...
             
            case APPLICATION_INFO_CHANGED:
                mUpdatingSystemConfig = true;
                try {
                    handleApplicationInfoChanged((ApplicationInfo) msg.obj);
                } finally {
                    mUpdatingSystemConfig = false;
                }
                break;

             //...
        }
    }
}

查看 handleApplicationInfoChanged 方法邏輯

 void handleApplicationInfoChanged(@NonNull final ApplicationInfo ai) {
    // ...
    
    handleConfigurationChanged(newConfig, null);

    // ...
}

接着看 handleConfigurationChanged 方法

final void handleConfigurationChanged(Configuration config, CompatibilityInfo compat) {

    //...

    ArrayList<ComponentCallbacks2> callbacks = collectComponentCallbacks(false, config);

    freeTextLayoutCachesIfNeeded(configDiff);

    if (callbacks != null) {
        final int N = callbacks.size();
        for (int i=0; i<N; i++) {
            ComponentCallbacks2 cb = callbacks.get(i);
            if (cb instanceof Activity) {
                // If callback is an Activity - call corresponding method to consider override
                // config and avoid onConfigurationChanged if it hasn't changed.
                Activity a = (Activity) cb;
                performConfigurationChangedForActivity(mActivities.get(a.getActivityToken()),
                        config);
            } else if (!equivalent) {
                performConfigurationChanged(cb, config);
            }
        }
    }
}

從判斷語句中可以看到,當 callbackActivity 時,會執行 performConfigurationChangedForActivity 方法,這個邏輯在分割線後就可以看到;

那麼 collectComponentCallbacks(false, config); 代碼的含義大概就是搜尋所有需要處理 config - change 的對象。

--------------------- 分割線

然後再查看 ACTIVITY_CONFIGURATION_CHANGED 這個消息:

找出這段代碼

private class H extends Handler {
    public void handleMessage(Message msg) {
        if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
        switch (msg.what) {
             //...
             
             case ACTIVITY_CONFIGURATION_CHANGED:
                Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityConfigChanged");
                handleActivityConfigurationChanged((ActivityConfigChangeData) msg.obj,
                        INVALID_DISPLAY);
                Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
                break;
                
             //...
        }
    }
}

進入handleActivityConfigurationChanged方法進行查看:

void handleActivityConfigurationChanged(ActivityConfigChangeData data, int displayId) {
    ActivityClientRecord r = mActivities.get(data.activityToken);
    // Check input params.
    if (r == null || r.activity == null) {
        if (DEBUG_CONFIGURATION) Slog.w(TAG, "Not found target activity to report to: " + r);
        return;
    }
    final boolean movedToDifferentDisplay = displayId != INVALID_DISPLAY
            && displayId != r.activity.getDisplay().getDisplayId();

    // Perform updates.
    r.overrideConfig = data.overrideConfig;
    final ViewRootImpl viewRoot = r.activity.mDecor != null
        ? r.activity.mDecor.getViewRootImpl() : null;

    if (movedToDifferentDisplay) {
        if (DEBUG_CONFIGURATION) Slog.v(TAG, "Handle activity moved to display, activity:"
                + r.activityInfo.name + ", displayId=" + displayId
                + ", config=" + data.overrideConfig);

        final Configuration reportedConfig = performConfigurationChangedForActivity(r,
                mCompatConfiguration, displayId, true /* movedToDifferentDisplay */);
        if (viewRoot != null) {
            viewRoot.onMovedToDisplay(displayId, reportedConfig);
        }
    } else {
        if (DEBUG_CONFIGURATION) Slog.v(TAG, "Handle activity config changed: "
                + r.activityInfo.name + ", config=" + data.overrideConfig);
        performConfigurationChangedForActivity(r, mCompatConfiguration);
    }
    // Notify the ViewRootImpl instance about configuration changes. It may have initiated this
    // update to make sure that resources are updated before updating itself.
    if (viewRoot != null) {
        viewRoot.updateConfiguration(displayId);
    }
    mSomeActivitiesChanged = true;
}

然後追蹤進入 performConfigurationChangedForActivity 方法

private Configuration performConfigurationChangedForActivity(ActivityClientRecord r,
        Configuration newBaseConfig, int displayId, boolean movedToDifferentDisplay) {
    r.tmpConfig.setTo(newBaseConfig);
    if (r.overrideConfig != null) {
        r.tmpConfig.updateFrom(r.overrideConfig);
    }
    final Configuration reportedConfig = performActivityConfigurationChanged(r.activity,
            r.tmpConfig, r.overrideConfig, displayId, movedToDifferentDisplay);
    freeTextLayoutCachesIfNeeded(r.activity.mCurrentConfig.diff(r.tmpConfig));
    return reportedConfig;
}

然後追蹤進入 performActivityConfigurationChanged 方法

 private Configuration performActivityConfigurationChanged(Activity activity,
            Configuration newConfig, Configuration amOverrideConfig, int displayId,
            boolean movedToDifferentDisplay) {
    // ...

    if (shouldChangeConfig) {
        activity.mCalled = false;
        activity.onConfigurationChanged(configToReport);
        if (!activity.mCalled) {
            throw new SuperNotCalledException("Activity " + activity.getLocalClassName() +
                            " did not call through to super.onConfigurationChanged()");
        }
    }

    //...
}

可以看到,最後是執行了 activityonConfigurationChanged 方法

-------------- 結語

隨着安卓api的提升,已有的功能都可能會有大的更改,類似的問題可能會越來越多,即便是之前看到的源碼,雖然大體邏輯不便,但還是可能有細微處的差別

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章