Android多语言切换以及如何改BUG(微笑)

引言

事情是这样的,我们接到一个需求,是要为我们的应用做多语言版本并且提供多语言切换。事后证明,这个事情还真的是很蛋疼的一件事。

在android系统中,应用的语言环境会跟随系统环境。如果在resource文件夹中,如果设置了对应语言环境的资源文件夹,那么在使用资源的时候会由AssetManager到对应的资源文件夹中取出展示。

如果想让应用不跟随系统环境,而是能使用自己的语言配置呢?这也不难,只要将context.getResoure().getConfiguration().locale改成设置的语言环境即可。

如何设置应用的app locale

  1. 添加多语言文本文件
    resource文件下增加不同语言的value文件夹,例如英文的添加value-en文件夹,繁体中文添加value-zh-rTW文件夹

    Android多语言切换以及如何改BUG(微笑)_第1张图片

  2. 更新configurationlocale属性
    android中,configuration包含了activity所有的配置信息,包括屏幕密度,屏幕宽度,语言设置等等。修改应用的configuration使应用根据configuration中配置的语言环境来展示资源。

public class LanguageUtil {
  /**
   * 设置应用语言类型
   */
    @SuppressWarnings("deprecation")
    public static void setAppLocale(Locale locale) {
      if (locale!=null) {
        Configuration configuration = context.getResources().getConfiguration();
        configuration.locale = locale;
      }
    }
}
  1. 重新启动activity
    已经启动了的activity当然不会自己把页面全部换一遍,最简单粗暴的方法当然是重新启动他们。把栈里的activity统统干掉,重新启动第一个activity
    如何能够保持所有的activity不需要重新启动?这是另一个问题了,这里不作讨论
public static void startMainNewTask(Context context) {
    Intent intent = new Intent(context, MainActivity.class);
    intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
    context.startActivity(intent);
  }
  1. 持久化存储应用locale配置
    使用sharePreference或者什么的存一下应用的语言设置,在下次启动应用的时候重新恢复一下即可。这里就不赘述了。

这样就OK了吗?

并没有。上述方法看起来很美,网上很多能搜到的资料也基本都止步于此。但是我们改的configuration,真的不会再变了吗?

事实上,configuration在很多的情况下会被系统所修改,比如在切换系统语言的时候、在横竖屏切换的时候等等。当configuration发生改变的时候,ActivityThread会拷贝一份系统的configuration,覆盖到appContext里。如果在configuration change发生之后,页面更新数据并且通过resource去获取文字的话,会以系统的locale为准去获取文字资源。

于是我们开始研究怎么改这个bug。

通过调试,发现每一次横竖屏切换过后,Application$onConfigurationChanged(Configuration newConfig)方法都会被调用一次。于是很自然地,我们想到,如果在这里我们把newConfig在调用super方法之前改掉,是不是就能够解决这个问题了?

很不幸,不是的。在Application$onConfigurationChanged(Configuration newConfig)被调用的时候,Resource#mResourceImpl#mConfiguration已经被修改了。从下面这段代码可以看出来:


public final class ActivityThread {
  
 ......

 final void handleConfigurationChanged(Configuration config, CompatibilityInfo compat) {
        ......
        synchronized (mResourcesManager) {
            ......
            // 这个方法最终会调用Resource$updateConfiguration方法,导致locale被覆盖
            mResourcesManager.applyConfigurationToResourcesLocked(config, compat);
            ......
        }
        
        ......

        if (callbacks != null) {
            final int N = callbacks.size();
            for (int i=0; i

同时,在Application$onConfigurationChanged方法里修改Configuration#locale会引起另外一个bug:Activity会不断地重启。表现在视觉上就是这个Activity启动之后一直在闪烁。这个是什么原因?

原因在于当Orientation发生改变的时候,ActivityManagerService会去检查新启动的ActivityConfiguration是否是一致的,否则会重新启动Activity,关键的代码是:


final class ActivityStack {

......

    /**
     * Make sure the given activity matches the current configuration. Returns false if the activity
     * had to be destroyed.  Returns true if the configuration is the same, or the activity will
     * remain running as-is for whatever reason. Ensures the HistoryRecord is updated with the
     * correct configuration and all other bookkeeping is handled.
     */
    boolean ensureActivityConfigurationLocked(
            ActivityRecord r, int globalChanges, boolean preserveWindow) {
        ......
       
        // Figure out how to handle the changes between the configurations.
        if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
                "Checking to restart " + r.info.name + ": changed=0x"
                + Integer.toHexString(changes) + ", handles=0x"
                + Integer.toHexString(r.info.getRealConfigChanged()) + ", newConfig=" + newConfig
                + ", taskConfig=" + taskConfig);

        if ((changes&(~r.info.getRealConfigChanged())) != 0 || r.forceNewConfig) {
            // Aha, the activity isn't handling the change, so DIE DIE DIE.
            r.configChangeFlags |= changes;
            r.startFreezingScreenLocked(r.app, globalChanges);
            r.forceNewConfig = false;
            preserveWindow &= isResizeOnlyChange(changes);
            if (r.app == null || r.app.thread == null) {
                if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
                        "Config is destroying non-running " + r);
                destroyActivityLocked(r, true, "config");
            } else if (r.state == ActivityState.PAUSING) {
                // A little annoying: we are waiting for this activity to finish pausing. Let's not
                // do anything now, but just flag that it needs to be restarted when done pausing.
                if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
                        "Config is skipping already pausing " + r);
                r.deferRelaunchUntilPaused = true;
                r.preserveWindowOnDeferredRelaunch = preserveWindow;
                return true;
            } else if (r.state == ActivityState.RESUMED) {
                // Try to optimize this case: the configuration is changing and we need to restart
                // the top, resumed activity. Instead of doing the normal handshaking, just say
                // "restart!".
                if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
                        "Config is relaunching resumed " + r);

                if (DEBUG_STATES && !r.visible) {
                    Slog.v(TAG_STATES, "Config is relaunching resumed invisible activity " + r
                            + " called by " + Debug.getCallers(4));
                }

                relaunchActivityLocked(r, r.configChangeFlags, true, preserveWindow);
            } else {
                if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
                        "Config is relaunching non-resumed " + r);
                relaunchActivityLocked(r, r.configChangeFlags, false, preserveWindow);
            }

            // All done...  tell the caller we weren't able to keep this activity around.
            return false;
        }

        // Default case: the activity can handle this new configuration, so hand it over.
        // NOTE: We only forward the task override configuration as the system level configuration
        // changes is always sent to all processes when they happen so it can just use whatever
        // system level configuration it last got.
        r.scheduleConfigurationChanged(taskConfig, true);
        r.stopFreezingScreenLocked(false);

        return true;
    }
}

既然在Application$onConfigurationChanged方法里无法修改locale。那么我们考虑在Activity$onResume方法里再更新一次locale行不行呢?在所有页面的基类BaseActivity执行如下测试代码:

public class BaseActivity extend AppCompatActivity {
  @Override
  protected void onResume() {
    super.onResume();
    // 语言状态检测
    recoverLanguage();
  }

  private void recoverLanguage() {
    // 系统语言是中文环境,将configuration#locale强制改成英文环境进行测试
    getResource().getConfiguration().locale = Locale.ENGLISH;   
  }
}

Activity$onResume里执行这样的代码,已经规避了Activity启动流程中对于Configuration的校验了,因为在Activity$onResume被执行的时候,校验已经结束了。

我们可以看到,Activity已经不再重复循环地去relaunch了。那么Configuration#locale修改成功了吗?修改成功了,但未起作用。通过调试,我们发现:

Android多语言切换以及如何改BUG(微笑)_第2张图片

从断点数据中我们可以看到, mResource.mResourceImpl.mConfiguration.locale已经是 Locale.ENGLISH了。
Android多语言切换以及如何改BUG(微笑)_第3张图片

但是通过执行 Context$getString方法我们却发现,取出来的文字是中文。这就耐人寻味了,为何原本修改 Resource中的 locale可以修改语言环境,而现在修改又不行了呢?

还是通过源码来探究一下。

Android多语言切换以及如何改BUG(微笑)_第4张图片
Android多语言切换以及如何改BUG(微笑)_第5张图片
Android多语言切换以及如何改BUG(微笑)_第6张图片

从这三段源码可以看到,Context$getString方法实际上,是通过AssetManager来获取StringRes的,那是不是说,AssetManager里面也有一个locale呢?

是的!通过查看源码,我们发现AssetManager里有一个native方法:

Android多语言切换以及如何改BUG(微笑)_第7张图片

原因就很明确了,虽然我们修改了Resourcelocale,却没有修改这里的,所以修改不生效。至此,解决办法就剩下:

  1. 通过反射,拿到'AssetManager'的这个方法,将locale设置进去。
  2. 通过寻找调用了这个方法的别的API,然后通过调用此API,更新进去。

反射的方法我并不喜欢,原因是这一个方法的参数列表太长了,反射的话写起来会很痛苦(微笑)

所以最终的解决办法是:

我们在Resourse里发现了一个方法:

Android多语言切换以及如何改BUG(微笑)_第8张图片
    public void updateConfiguration(Configuration config, DisplayMetrics metrics,
                                    CompatibilityInfo compat) {
        Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, "ResourcesImpl#updateConfiguration");
        try {
            synchronized (mAccessLock) {
                ......
                mAssets.setConfiguration(mConfiguration.mcc, mConfiguration.mnc,
                        adjustLanguageTag(mConfiguration.getLocales().get(0).toLanguageTag()),
                        mConfiguration.orientation,
                        mConfiguration.touchscreen,
                        mConfiguration.densityDpi, mConfiguration.keyboard,
                        keyboardHidden, mConfiguration.navigation, width, height,
                        mConfiguration.smallestScreenWidthDp,
                        mConfiguration.screenWidthDp, mConfiguration.screenHeightDp,
                        mConfiguration.screenLayout, mConfiguration.uiMode,
                        Build.VERSION.RESOURCES_SDK_INT);

                ......
            }
            synchronized (sSync) {
                if (mPluralRule != null) {
                    mPluralRule = PluralRules.forLocale(mConfiguration.getLocales().get(0));
                }
            }
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
        }
    }

那么只需要在Activity$onResume里添加如下代码:

public class BaseActivity extend AppCompatActivity {
  @Override
  protected void onResume() {
    super.onResume();
    // 语言状态检测
    recoverLanguage();
  }

  /**
   * 通过updateConfiguration方法修改Resource的Locale,连带修改Resource内Asset的Local.
   */
  private void recoverLanguage() {
    Resources resources = getContext().getResources();
    Configuration configuration = resources.getConfiguration();
    DisplayMetrics metrics = resources.getDisplayMetrics();
    // 从Preference中取出语言设置
    configuration.locale = PreferenceUtil.getCustomLanguageSetting();
    resources.updateConfiguration(configuration, metrics);
  }
}

你可能感兴趣的:(Android多语言切换以及如何改BUG(微笑))