Android App 跟随系统自动切换白天/黑夜模式:车机项目实战经验分享

在车机(Android Automotive)项目开发中,用户经常会在白天和夜晚切换车辆的仪表盘主题,这时我们的 App 也需要自动跟随系统切换到对应的白天或黑夜 UI,避免刺眼或看不清内容。

本文基于一个真实的车机用户报告 App 项目,完整分享如何优雅实现 App 跟随系统自动切换暗色模式 ,同时解决切换过程中常见的界面重影、页面跳回、数据重置 等问题。最后还会回答大家最关心的:如果 App 有多个 Activity(多个界面)该怎么处理?

一、核心实现:三步搞定自动跟随系统暗色模式

1. 主题继承 DayNight 主题(必须)

res/values/themes.xml 中:

xml 复制代码
<style name="Theme.CheryUserReport" parent="Theme.AppCompat.DayNight.NoActionBar">
    <!-- 你的自定义颜色、样式 -->
    <item name="colorPrimary">@color/main_color</item>
    <!-- ... -->
</style>

这样 AppCompat 就能自动根据系统暗色模式加载对应的资源:

  • 白天:加载 res/values/res/drawable/
  • 黑夜:自动加载 res/values-night/res/drawable-night/
2. 在 Application 中设置跟随系统(最佳位置)
java 复制代码
public class UserReportApplication extends Application {
    @Override
    public void onCreate() {
        // 必须最先设置,确保所有 Activity 创建前生效
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);

        super.onCreate();
        
        ContextHolder.init(this);
        LogUtils.i("CheryUserApp", "Application onCreate");
    }
}

放在 Application 是最佳实践,比放在 Activity 更早、更全局、更安全。

java 复制代码
package com.chery.userreport;

import android.os.Build;
import android.os.Bundle;
import android.view.Window;
import android.view.WindowInsetsController;
import android.widget.RadioButton;
import android.widget.RadioGroup;

import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;

import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;

import com.chery.userreport.drivingbehavior.DrivingBehaviorFragment;
import com.chery.userreport.energyanalysis.EnergyAnalysisFragment;
import com.chery.userreport.travelreport.TravelReportFragment;
import com.chery.userreport.energystatistics.EnergyStatisticsFragment;

public class MainActivity extends AppCompatActivity {

    private FragmentManager fragmentManager;
    private Fragment currentFragment;

    private static final String KEY_CURRENT_POSITION = "current_position";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        setupEdgeToEdge();

        if (getSupportActionBar() != null) {
            getSupportActionBar().hide();
        }

        fragmentManager = getSupportFragmentManager();

        RadioGroup radioGroup = findViewById(R.id.main_radio_group);

        int currentPosition = 0; // 默认位置

        if (savedInstanceState != null) {
            // recreate 时恢复上次保存的位置
            currentPosition = savedInstanceState.getInt(KEY_CURRENT_POSITION, 0);
            // 系统已经自动恢复了之前 add 的 Fragment,直接查找当前显示的
            currentFragment = fragmentManager.findFragmentById(R.id.main_fragment_container);
        }

        // 显示对应的 Fragment
        showFragment(currentPosition);

        // 恢复 RadioGroup 选中状态
        checkRadioButtonByPosition(radioGroup, currentPosition);

        radioGroup.setOnCheckedChangeListener((group, checkedId) -> {
            // 兼容你的布局:只有第一个有 id,后三个没有,所以用 indexOfChild 计算位置
            RadioButton checkedButton = findViewById(checkedId);
            int position = radioGroup.indexOfChild(checkedButton);
            showFragment(position);
        });
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        // 保存当前 tab 位置
        outState.putInt(KEY_CURRENT_POSITION, getCurrentPosition());
    }

    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    private void setupEdgeToEdge() {
        Window window = getWindow();
        window.setDecorFitsSystemWindows(false);
        window.setStatusBarColor(ContextCompat.getColor(this, R.color.main_bg));

        WindowInsetsController controller = window.getInsetsController();
        if (controller != null) {
            controller.setSystemBarsAppearance(
                    WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS,
                    WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
            );
        }

        ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content), (v, windowInsets) -> {
            Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(insets.left, insets.top, insets.right, insets.bottom);
            return windowInsets;
        });
    }

    private void showFragment(int position) {
        // 先尝试复用已存在的 Fragment
        String tag = getFragmentTag(position);
        Fragment targetFragment = fragmentManager.findFragmentByTag(tag);

        if (targetFragment == null) {
            targetFragment = createFragment(position);
        }

        FragmentTransaction transaction = fragmentManager.beginTransaction();

        // 隐藏当前 Fragment
        if (currentFragment != null && currentFragment != targetFragment) {
            transaction.hide(currentFragment);
        }

        // 显示目标 Fragment
        if (targetFragment.isAdded()) {
            transaction.show(targetFragment);
        } else {
            transaction.add(R.id.main_fragment_container, targetFragment, tag);
        }

        transaction.commitNowAllowingStateLoss();
        currentFragment = targetFragment;
    }

    private Fragment createFragment(int position) {
        switch (position) {
            case 0:
                return new EnergyStatisticsFragment();
            case 1:
                return new EnergyAnalysisFragment();
            case 2:
                return new DrivingBehaviorFragment();
            case 3:
                return new TravelReportFragment();
            default:
                return new EnergyStatisticsFragment();
        }
    }

    private String getFragmentTag(int position) {
        return "fragment_" + position;
    }

    private int getCurrentPosition() {
        if (currentFragment == null) return 0;
        String tag = currentFragment.getTag();
        if (tag != null && tag.startsWith("fragment_")) {
            try {
                return Integer.parseInt(tag.substring("fragment_".length()));
            } catch (NumberFormatException e) {
                return 0;
            }
        }
        return 0;
    }

    /** 根据位置选中对应的 RadioButton(兼容无 id 的情况) */
    private void checkRadioButtonByPosition(RadioGroup radioGroup, int position) {
        if (position >= 0 && position < radioGroup.getChildCount()) {
            RadioButton button = (RadioButton) radioGroup.getChildAt(position);
            button.setChecked(true);
        }
    }
}
3. 使用 -night 资源限定符定义夜间 UI
  • res/values/colors.xml → 日间颜色
  • res/values-night/colors.xml → 夜间颜色
  • res/drawable/icon_day.png → 日间图标
  • res/drawable-night/icon_night.png → 夜间图标

系统切换时,App 会自动加载对应资源,无需手动刷新颜色。

二、切换时常见问题及解决方案

车机系统切换暗色模式会触发 Activity recreate(),这会导致一系列问题:

问题1:界面重影(多个 Fragment 叠加)

原因 :原始代码每次切换 tab 都 remove + add 新 Fragment,recreate 后系统自动恢复旧 Fragment,你又 add 了一个新的一样的 → 重影。

解决 :改为 hide/show + 复用 Fragment 实例 ,并正确处理 savedInstanceState

(具体代码见之前的 MainActivity 完整实现)

问题2:切换后页面跳回第一个 tab

解决 :在 onSaveInstanceState 保存当前 tab 位置,recreate 时恢复并选中对应 RadioButton

问题3:Fragment 内数据重置(最常见!)

原因:recreate 后 Fragment 重新创建,onViewCreated 中又重新请求网络/数据库数据。

最佳解决 :使用 ViewModel 保存业务数据

java 复制代码
class EnergyStatisticsViewModel extends ViewModel {
    private MutableLiveData<List<Data>> data = new MutableLiveData<>();
    
    public void loadData() {
        // 只加载一次,或根据需要刷新
        // 数据存到 LiveData,recreate 不丢失
    }
}

class EnergyStatisticsFragment extends Fragment {
    private EnergyStatisticsViewModel viewModel;

    @Override
    public void onViewCreated(...) {
        viewModel = new ViewModelProvider(this).get(EnergyStatisticsViewModel.class);
        
        viewModel.getData().observe(getViewLifecycleOwner(), list -> {
            updateUI(list);
        });

        if (viewModel.getData().getValue() == null) {
            viewModel.loadData(); // 只在数据为空时加载
        }
    }
}

ViewModel 会存活于 Activity recreate,数据完美保留。

另外,RecyclerView 滚动位置、EditText 输入内容等,Android 会自动恢复(前提是 View 有 id)。

三、多个 Activity(多个界面)怎么处理?

这是大家最关心的问题!答案很简单:

你什么都不需要额外做!

因为:

  1. 你已经在 Application.onCreate() 中全局设置了:

    java 复制代码
    AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_FOLLOW_SYSTEM);

    所有 Activity 都自动生效,无需每个 Activity 重复写代码。

  2. 所有 Activity 的主题都继承自 Theme.AppCompat.DayNight.*(通常在 themes.xml 中统一定义 AppTheme)

  3. 系统切换暗色模式时,会同时 recreate 所有当前在栈中的 Activity,每个 Activity 都会自动加载对应 -night 资源

实际处理建议

  • 每个 Activity 同样使用 hide/show 管理 Fragment(如果有多个 Fragment)
  • 每个页面使用 ViewModel 保存关键数据
  • 如果有需要全局共享的数据(如用户登录状态、主题偏好),可以放在 Application 或 Singleton 中

这样无论你的 App 有 1 个还是 10 个 Activity,切换系统暗色模式时:

  • 所有界面自动变暗/变亮
  • 当前页面不跳转
  • 数据不丢失
  • 无重影、无闪烁(仅短暂重绘,正常现象)

四、总结:最佳实践清单

步骤 操作 说明
1 主题继承 Theme.AppCompat.DayNight 启用自动资源切换
2 在 Application 中设置 MODE_NIGHT_FOLLOW_SYSTEM 全局生效,最早执行
3 使用 -night 文件夹定义夜间资源 自动加载
4 Fragment 用 hide/show + tag 复用 避免重影
5 保存/恢复当前 tab 位置 页面不跳回
6 使用 ViewModel 保存业务数据 数据不重置
7 多 Activity 项目无需额外处理 自动全局生效

做完这几步,你的车机 App 就能完美跟随系统切换白天黑夜模式,用户体验大幅提升!

如果你正在开发车机或需要支持暗色模式的 App,强烈推荐按这个方案实施,亲测稳定可靠。

欢迎留言讨论你的实现方式~

(本文代码已在真实车机项目中运行半年+,稳定无问题)

相关推荐
成都大菠萝2 小时前
2-2-2 快速掌握Kotlin-语言的接口默认实现
android
代码s贝多芬的音符2 小时前
android webview 打开相机 相册 图片上传。
android·webview·webview打开相机相册
游戏开发爱好者82 小时前
抓包工具有哪些?代理抓包、数据流抓包、拦截转发工具
android·ios·小程序·https·uni-app·iphone·webview
StarShip3 小时前
Android system_server进程介绍
android
StarShip3 小时前
Android Context 的 “上下文”
android
成都大菠萝3 小时前
2-6-1 快速掌握Kotlin-语言的接口定义
android
李小轰_Rex3 小时前
纯算法AEC:播录并行场景的回声消除实战笔记
android·音视频开发
ok406lhq4 小时前
unity游戏调用SDK支付返回游戏会出现画面移位的问题
android·游戏·unity·游戏引擎·sdk
成都大菠萝5 小时前
2-2-2 快速掌握Kotlin-函数&Lambda
android