【Android基础】系统语言和主题切换

【Android基础】系统语言和主题切换

本文介绍了车载Android系统的语言切换和主题切换的触发以及适配

以下各个api的开发与测试均在车载设备上,可能在手机上不一定生效。

并且主动触发系统环境切换的方法,只有系统权限的app才可以调用。

触发入口调用

一般厂商,都会自己定义一个切换入口,像系统设置,或者桌面的通知页面。这里面去调用系统的api,来达到切换的目的。

主题切换

主题切换可以直接使用系统的UiModeManager即可。

    private val uimodeManager =
        appContext.getSystemService(UI_MODE_SERVICE) as UiModeManager

例如深浅主题切换调用:

// UiModeManager.java
    /**
     * Sets the system-wide night mode.
     * 
     * @param mode the night mode to set
     * @see #getNightMode()
     * @see #setApplicationNightMode(int)
     */
    public void setNightMode(@NightMode int mode) {
        if (mService != null) {
            try {
                mService.setNightMode(mode);
            } catch (RemoteException e) {
                throw e.rethrowFromSystemServer();
            }
        }
    }

我们自定义的触发按钮,按下时可以这样设置:

mMainView.findViewById<Button>(R.id.btn_night).setOnClickListener {
    uimodeManager.nightMode = UiModeManager.MODE_NIGHT_YES
}

mMainView.findViewById<Button>(R.id.btn_light).setOnClickListener {
    uimodeManager.nightMode = UiModeManager.MODE_NIGHT_NO
}

语言切换

通过反射调用ActivityManager的updatePersistentConfiguration方法,即可实现系统语言切换。

    /**
     * 切换语言
     * @param language 语言
     */
    fun changeLanguageSettings(language: Locale) {
        try {
            val activityManagerNative = Class.forName("android.app.ActivityManager")
            val am = activityManagerNative.getMethod("getService").invoke(activityManagerNative)
            val config = am?.javaClass?.getMethod("getConfiguration")
                ?.invoke(am) as Configuration
            config.setLocale(language)
            config.javaClass.getDeclaredField("userSetLocale").setBoolean(config, true)
            am.javaClass.getMethod("updatePersistentConfiguration", config.javaClass)
                .invoke(am, config)
            BackupManager.dataChanged("com.android.providers.settings")
        } catch (e: Exception) {
            e.message?.let { error(it) }
        }
    }

触发按钮调用:

mMainView.findViewById<Button>(R.id.btn_chinese).setOnClickListener {
    changeLanguageSettings(Locale.SIMPLIFIED_CHINESE)
}

mMainView.findViewById<Button>(R.id.btn_english).setOnClickListener {
    changeLanguageSettings(Locale.ENGLISH)
}

适配方app

在上面的app完成触发之后,系统会将环境切换到对应的深浅模式,或者对应的语言状态下,这时候其他的app就需要响应刷新自己的页面。

在开发过程中,可以按下面的几种方法来适配。

资源目录设置

首先不管是Activity应用还是浮窗应用,我们都需要在res资源目录下添加对应的语言和主题的资源目录。

语言目录

以英文为例,新建value-en目录,将翻译之后的strings.xml复制进去即可,字段的名称和中文目录是一致的。

主题目录

深色的主题资源放在带-night后缀的目录下。 例如图片等资源,放置于drawable-mdpi-night目录下,color字段放置于values-night目录下。和语言切换一样,图片,颜色等文件名称和浅色主题一致,切换的时候使用 R 类会自己索引。

逻辑代码

Activity型应用

切换主题和语言时,Activity都会销毁重建,按照触发顺序,大致为:

  • onConfigurationChanged:当系统配置发生变化时,例如屏幕方向改变或主题切换,Activity会首先调用onConfigurationChanged方法。你可以重写这个方法来处理配置变化,例如重新加载资源或更新UI。
  • onSaveInstanceState:在Activity可能被销毁之前,系统会调用onSaveInstanceState方法,允许你保存一些关键的状态信息,以便在Activity重新创建时恢复。
  • 全部的流程如下
{thread:main(2) MainActivity:431 onPause} 
{thread:main(2) MainActivity:495 onStop} 
{thread:main(2) MainActivity:170 onSaveInstanceState} 
{thread:main(2) MainActivity:500 onDestroy} 
{thread:main(2) MainActivity:131 onCreate} 
{thread:main(2) MainActivity:180 initData} 
{thread:main(2) MainActivity:400 initView} 
{thread:main(2) MainActivity:155 onStart} 
{thread:main(2) MainActivity:175 onResume} 
{thread:main(2) MainActivity:481 onWindowFocusChanged} onWindowFocusChanged hasFocus: true

重建之后,Activity会按照提前设置好的资源目录进行资源的获取,自动地刷新界面。切到浅色主题,就会拿取drawable-mdpi目录下的资源,深色主题则会拿取drawable-mdpi-night目录下的资源。

Service加浮窗型应用

在车机开发中,经常会设计一些临时性的悬浮窗app,例如天气,时间,快捷车控等功能。

这类app一般的架构为,开机之后,会启动一个Service,然后在Service中获取WindowManager,来进行悬浮窗的添加移除等管理操作。

// LanguageService.kt
    private val mWmParams = WindowManager.LayoutParams().apply {
        //设置可以显示在状态栏上
        flags = (WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
                or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
                or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
                or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
        type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
        //设置窗口长宽数据
        width = WindowManager.LayoutParams.WRAP_CONTENT
        height = WindowManager.LayoutParams.WRAP_CONTENT
        gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
        x = 600
        y = 0
        format = PixelFormat.TRANSLUCENT
    }

    private val mWindowManager = appContext.getSystemService(WINDOW_SERVICE) as WindowManager

    private lateinit var mMainView: View

    fun showWindow(){
        mMainView = LayoutInflater.from(appContext).inflate(R.layout.layout_language_change, null, false)

        mWindowManager.addView(mMainView, mWmParams)
    }

不像Activity都是自动化,高层级的悬浮窗app的生命周期比较复杂,需要我们自己去管理。

这时候系统不会自动去重走生命周期,刷新资源了,我们需要手动去切换主题和语言。

首先,需要在service中复写onConfigurationChanged方法。系统在语言和主题切换时,会调用这个方法。主题其他类型的配置切换,例如旋转屏幕等,也会走这个方法。

所以需要设置一个主题(语言)管理类,对比前后的状态,是否这个触发的类型是主题(语言)切换。

监听到了变化之后,有两种方案:

手动刷新置换资源

第一种,对于界面简单的浮窗界面,可以直接在onConfigurationChanged中,重新加载资源,然后重新设置布局。这种方法不用考虑窗口的状态,直接对每个View进行定点刷新,不容易出问题。

语言切换:

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        LogUtils.i(TAG, "onConfigurationChanged")
        mMainView.findViewById<Button>(R.id.btn_chinese).text = getString(R.string.chinese)
        mMainView.findViewById<Button>(R.id.btn_english).text = getString(R.string.english)
    }

主题切换:

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        LogUtils.i(TAG, "onConfigurationChanged")
        mMainView.setBackgroundColor(
            ResourcesCompat.getColor(this.resources, R.color.theme_test, null)
        )
        mMainView.findViewById<ImageView>(R.id.iv_close)
            .setImageResource(R.drawable.ic_close_dialog)
    }
置空重建

第二种方案比较适合复杂的View,内部组件众多,挨个手动替换比较麻烦。

这时就可以模仿Activity的切换方式,直接移除掉之前的view,将其置空后,重新创建一个view,设置好子View的监听方法,然后重新添加到windowManager中。

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        LogUtils.i(TAG, "onConfigurationChanged")
        mWindowManager.removeView(mMainView)
        mMainView = null
        mMainView =
            LayoutInflater.from(appContext)
                .inflate(R.layout.layout_language_change, null, false)
        initViews()
        mWindowManager.addView(mMainView, mWmParams)
    }

这种方法简单粗暴,但是必须要管控好窗口的状态和置空的时机,否则可能会导致内存泄漏。

而且这种方法也会导致窗口的闪烁,最好在系统切换时有一个过度的效果动画。