Androidx 切换多语言失效解决方案(appcompat版本有关)

2021-04-25
Androidx 切换多语言失效解决方案(appcompat版本有关)

项目经过Androidx改造后,多语言切换会失效,这里汇总下。


当引用了androidx.appcompat:appcompat:1.1.0时,BaseActivity中实现下面方法:


@Override

public void applyOverrideConfiguration(Configuration overrideConfiguration) {

    // 兼容androidX在部分手机切换语言失败问题

    if (overrideConfiguration != null) {

        int uiMode = overrideConfiguration.uiMode;

        overrideConfiguration.setTo(getBaseContext().getResources().getConfiguration());

        overrideConfiguration.uiMode = uiMode;

    }

    super.applyOverrideConfiguration(overrideConfiguration);

}

 

当引用了androidx.appcompat:appcompat:1.2.0时,BaseActivity中实现下面方法:


// BaseActivity继承AppCompatActivity

// 修复appcompat 1.2+版本导致多语言切换失败,传自定义的ContextThemeWrapper

@Override

protected void attachBaseContext(Context newBase) {

    if (shouldSupportMultiLanguage()) {

        Integer language = SpUtil.getInt(newBase, Cons.SP_KEY_OF_CHOOSED_LANGUAGE, -1);

        Context context = LanguageUtil.attachBaseContext(newBase, language);

        final Configuration configuration = context.getResources().getConfiguration();

        // 此处的ContextThemeWrapper是androidx.appcompat.view包下的

        // 你也可以使用android.view.ContextThemeWrapper,但是使用该对象最低只兼容到API 17

        // 所以使用 androidx.appcompat.view.ContextThemeWrapper省心

        final ContextThemeWrapper wrappedContext = new ContextThemeWrapper(context,

            R.style.Theme_AppCompat_Empty) {

            @Override

            public void applyOverrideConfiguration(Configuration overrideConfiguration) {

                if (overrideConfiguration != null) {

                    overrideConfiguration.setTo(configuration);

                }

                super.applyOverrideConfiguration(overrideConfiguration);

            }

        };

        super.attachBaseContext(wrappedContext);

    } else {

        super.attachBaseContext(newBase);

    }

}

 

// 下面是多语言切换方法

public static Context attachBaseContext(Context context, Integer language) {

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {

        return createConfigurationResources(context, language);

    } else {

        applyLanguage(context, language);

        return context;

    }

}

 

public static void applyLanguage(Context context, Integer newLanguage) {

    Resources resources = context.getResources();

    Configuration configuration = resources.getConfiguration();

    Locale locale = getSupportLanguage(newLanguage);

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {

        DisplayMetrics dm = resources.getDisplayMetrics();

        // apply locale

        configuration.setLocale(locale);

        resources.updateConfiguration(configuration, dm);

    } else {

        // updateConfiguration

        DisplayMetrics dm = resources.getDisplayMetrics();

        configuration.locale = locale;

        resources.updateConfiguration(configuration, dm);

    }

}

    

@TargetApi(Build.VERSION_CODES.N)

private static Context createConfigurationResources(Context context, Integer language) {

    Resources resources = context.getResources();

    final Configuration configuration = resources.getConfiguration();

    final DisplayMetrics dm = resources.getDisplayMetrics();

    Locale locale;

    if (language < 0) {

      // 如果没有指定语言使用系统首选语言

      locale = getSystemPreferredLanguage();

    } else {

      // 指定了语言使用指定语言,没有则使用首选语言

      locale = getSupportLanguage(language);

    }

    configuration.setLocale(locale);

    resources.updateConfiguration(configuration, dm);

    return context;

}

 

/**

* 获取系统首选语言

*

* @return Locale

*/

@RequiresApi(api = Build.VERSION_CODES.N)

public static Locale getSystemPreferredLanguage() {

    Locale locale;

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {

        locale = LocaleList.getDefault().get(0);

    } else {

        locale = Locale.getDefault();

    }

    return locale;

}

 

// getSupportLanguage为自定义方法,看各位需求,自定义需要的Locale就行

 

升级后为什么会出现这个问题?


根据之前版本逻辑,多语言实现主要通过覆写AppCompatActivit的attachBaseContext 来实现,所以从此方法入手。


// AppCompatActivit 中的实现

@Override

protected void attachBaseContext(Context newBase) {

    super.attachBaseContext(getDelegate().attachBaseContext2(newBase));

}

可以看到AppCompatActivit 中是通过代理对我们传进来的newBase进行了一些处理。再来看getDelegate()方法,该方法创建了一个AppCompatDelegate。


/**

* @return The {@link AppCompatDelegate} being used by this Activity.

*/

@NonNull

public AppCompatDelegate getDelegate() {

    if (mDelegate == null) {

        mDelegate = AppCompatDelegate.create(this, this);

    }

    return mDelegate;

找到AppCompatDelegate的实现类AppCompatDelegateImpl ,定位到 attachBaseContext2(Context)方法 。


// AppCompatDelegateImpl

...

 

/**

* Flag indicating whether we can return a different context from attachBaseContext().

* Unfortunately, doing so breaks Robolectric tests, so we skip night mode application there.

*/

private static final boolean sCanReturnDifferentContext =

            !"robolectric".equals(Build.FINGERPRINT);

 

/**

 * Flag indicating whether ContextThemeWrapper.applyOverrideConfiguration() is available.

 */

private static final boolean sCanApplyOverrideConfiguration = Build.VERSION.SDK_INT >= 17;

 

...

 

@NonNull

@Override

@CallSuper

public Context attachBaseContext2(@NonNull final Context baseContext) {

    mBaseContextAttached = true;

    // This is a tricky method. Here are some things to avoid:

    // 1. Don't modify the configuration of the Application context. All changes should remain

    //    local to the Activity to avoid conflicting with other Activities and internal logic.

    // 2. Don't use createConfigurationContext() with Robolectric because Robolectric relies on

    //    method overrides.

    // 3. Don't use createConfigurationContext() unless you're able to retain the base context's

    //    theme stack. Not the last theme applied -- the entire stack of applied themes.

    final int modeToApply = mapNightMode(baseContext, calculateNightMode());

    // If the base context is a ContextThemeWrapper (thus not an Application context)

    // and nobody's touched its Resources yet, we can shortcut and directly apply our

    // override configuration.

    if (sCanApplyOverrideConfiguration

            && baseContext instanceof android.view.ContextThemeWrapper) {

        // api>=17 并且通过attachBaseContext传递的对象需要是android.view.ContextThemeWrapper

        // 上面的解决方案就是让AppCompatActivity中attachBaseContext方法代理程序进入此段代码

        // 来达到返回自定义的ContextThemeWrapper,然后覆写applyOverrideConfiguration来实现

        // 修改Configuration中local的功能

        final Configuration config = createOverrideConfigurationForDayNight(

                baseContext, modeToApply, null);

        if (DEBUG) {

            Log.d(TAG, String.format("Attempting to apply config to base context: %s",

                    config.toString()));

        }

        try {

            ContextThemeWrapperCompatApi17Impl.applyOverrideConfiguration(

                    (android.view.ContextThemeWrapper) baseContext, config);

            return baseContext;

        } catch (IllegalStateException e) {

            if (DEBUG) {

                Log.d(TAG, "Failed to apply configuration to base context", e);

            }

        }

    }

    // Again, but using the AppCompat version of ContextThemeWrapper.

    if (baseContext instanceof ContextThemeWrapper) {

        // 通过attachBaseContext传递的对象需要是androidx.appcompat.view.ContextThemeWrapper

        // 上面的解决方案就是让AppCompatActivity中attachBaseContext方法代理程序进入此段代码

        // 来达到返回自定义的ContextThemeWrapper,然后覆写applyOverrideConfiguration来实现

        // 修改Configuration中local的功能

        final Configuration config = createOverrideConfigurationForDayNight(

                baseContext, modeToApply, null);

        if (DEBUG) {

            Log.d(TAG, String.format("Attempting to apply config to base context: %s",

                    config.toString()));

        }

        try {

            ((ContextThemeWrapper) baseContext).applyOverrideConfiguration(config);

            return baseContext;

        } catch (IllegalStateException e) {

            if (DEBUG) {

                Log.d(TAG, "Failed to apply configuration to base context", e);

            }

        }

    }

    // We can't apply the configuration directly to the existing base context, so we need to

    // wrap it. We can't create a new configuration context since the app may rely on method

    // overrides or a specific theme -- neither of which are preserved when creating a

    // configuration context. Instead, we'll make a best-effort at wrapping the context and

    // rebasing the original theme.

    if (!sCanReturnDifferentContext) {

        return super.attachBaseContext2(baseContext);

    }

    // We can't trust the application resources returned from the base context, since they

    // may have been altered by the caller, so instead we'll obtain them directly from the

    // Package Manager.

    final Configuration appConfig;

    try {

        appConfig = baseContext.getPackageManager().getResourcesForApplication(

                baseContext.getApplicationInfo()).getConfiguration();

    } catch (PackageManager.NameNotFoundException e) {

        throw new RuntimeException("Application failed to obtain resources from itself", e);

    }

    // The caller may have directly modified the base configuration, so we'll defensively

    // re-structure their changes as a configuration overlay and merge them with our own

    // night mode changes. Diffing against the application configuration reveals any changes.

    final Configuration baseConfig = baseContext.getResources().getConfiguration();

    final Configuration configOverlay;

    if (!appConfig.equals(baseConfig)) {

        configOverlay = generateConfigDelta(appConfig, baseConfig);

        if (DEBUG) {

            Log.d(TAG,

                    "Application config (" + appConfig + ") does not match base config ("

                            + baseConfig + "), using base overlay: " + configOverlay);

        }

    } else {

        configOverlay = null;

        if (DEBUG) {

            Log.d(TAG, "Application config (" + appConfig + ") matches base context "

                    + "config, using empty base overlay");

        }

    }

    final Configuration config = createOverrideConfigurationForDayNight(

            baseContext, modeToApply, configOverlay);

    if (DEBUG) {

        Log.d(TAG, String.format("Applying night mode using ContextThemeWrapper and "

                + "applyOverrideConfiguration(). Config: %s", config.toString()));

    }

    // Next, we'll wrap the base context to ensure any method overrides or themes are left

    // intact. Since ThemeOverlay.AppCompat theme is empty, we'll get the base context's theme.

    // 如果没有通过attachBaseContext传递自定义ContextThemeWrapper,那么最终AppCompatActivity

    // 得到的Context对象为此处new的ContextThemeWrapper,所以我们无法覆写applyOverrideConfiguration

    // 最终也正是因为包裹了这一层,导致我们获取的Resources是此处ContextThemeWrapper的Resources,

    // 而非我们修改语言后的Resources对象

    final ContextThemeWrapper wrappedContext = new ContextThemeWrapper(baseContext,

            R.style.Theme_AppCompat_Empty);

    wrappedContext.applyOverrideConfiguration(config);

    // Check whether the base context has an explicit theme or is able to obtain one

    // from its outer context. If it throws an NPE because we're at an invalid point in app

    // initialization, we don't need to worry about rebasing under the new configuration.

    boolean needsThemeRebase;

    try {

        needsThemeRebase = baseContext.getTheme() != null;

    } catch (NullPointerException e) {

        needsThemeRebase = false;

    }

    if (needsThemeRebase) {

        // Attempt to rebase the old theme within the new configuration. This will only

        // work on SDK 23 and up, but it's unlikely that we're keeping the base theme

        // anyway so maybe nobody will notice. Note that calling getTheme() will clone

        // the base context's theme into the wrapped context's theme.

        ResourcesCompat.ThemeCompat.rebase(wrappedContext.getTheme());

    }

    return super.attachBaseContext2(wrappedContext);

}

通过上面代码的分析,可以发现,如果在BaseActivity中覆写attachBaseContext(Context) 传递的对象非ContextThemeWrapper,那么系统就会自己创建一个ContextThemeWrapper,然后将我们传递的Context作为参数进行一层包裹。最终问题就出在这段代码,系统创建的 ContextThemeWrapper的Resource并没有使用我们Context中的Resource,而是自己也单独定义了一个Resource,现在就是单独定义的 Resource中 Configuration采用的是系统当前语言,比如中国地区,就是中文,而非我们设定的语言。


最终我们切换语言,都是通过getResources()去获取不同语言的资源,所以再来看看 AppCompatActivit中 Resources获取逻辑。


// AppCompatActivit

@Override

public Resources getResources() {

    if (mResources == null && VectorEnabledTintResources.shouldBeUsed()) {

        mResources = new VectorEnabledTintResources(this, super.getResources());

    }

    return mResources == null ? super.getResources() : mResources;

通过上面代码,可以清楚的看到,如果AppCompatActivit中mResources == null,那么就会调用 super.getResources(),而该方法得到的对象为ContextThemeWrapper(android.view包下面的,这是没有传自定义ContextThemeWrapper时获取的内容,如果传了自定义对象,则该值为ContextImpl)。我在测试阶段,发现AppCompatActivit不持有Resources,最终都是通过父类上下文(ContextThemeWrapper或者ContextImpl)获取Resources。


// android.view.ContextThemeWrapper

 

@Override

public Resources getResources() {

    return getResourcesInternal();

}

private Resources getResourcesInternal() {

    if (mResources == null) {

        if (mOverrideConfiguration == null) {

            mResources = super.getResources();

        } else {

            final Context resContext = createConfigurationContext(mOverrideConfiguration);

            mResources = resContext.getResources();

        }

    }

    return mResources;

}

结合上面的代码,就可以知道为什么AppCompatDelegateImpl中attachBaseContext2(Context)返回的Context仅仅是把我们传递的Context包裹一层就导致多语言切换无效。因为attachBaseContext2(Context)返回的ContextThemeWrapper对象中的Resources不为null,而该Resources又没有应用我们设置的Context中Resources的Configuration对象,所以多语言设置无效了。


要清楚的理解Resources获取流程,建议Debug。通过Debug发现,如果不传自定义ContextThemeWrapper,则AppCompatActivit以及父类持有 Resources结构如下:


 


AppCompatActivit

-mResources(值为null)

-mBase(AppCompatActivit父类(super)上下文,ContextThemeWrapper)

    -mResources(不为null)

      -mResourcesImpl

       -mConfigration(使用的系统当前语言,非我们设置的语言,也是不传自定义ContextThemeWrapper最终采用的配置)

    -mBase(ContextThemeWrapper父类上下文,ContextImpl)

      -mResources(不为null)

        -mResourcesImpl

         -mConfigration(我们配置的语言)

 

 

传自定义ContextThemeWrapper后AppCompatActivit结构:

AppCompatActivit

-mResources(值为null)

-mBase(AppCompatActivit父类(super)上下文,ContextImpl)

      -mResources(不为null)

        -mResourcesImpl

         -mConfigration(我们配置的语言)