在车机(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(多个界面)怎么处理?
这是大家最关心的问题!答案很简单:
你什么都不需要额外做!
因为:
-
你已经在
Application.onCreate()中全局设置了:javaAppCompatDelegate.setDefaultNightMode(MODE_NIGHT_FOLLOW_SYSTEM);→ 所有 Activity 都自动生效,无需每个 Activity 重复写代码。
-
所有 Activity 的主题都继承自
Theme.AppCompat.DayNight.*(通常在 themes.xml 中统一定义 AppTheme) -
系统切换暗色模式时,会同时 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,强烈推荐按这个方案实施,亲测稳定可靠。
欢迎留言讨论你的实现方式~
(本文代码已在真实车机项目中运行半年+,稳定无问题)