\\n\\n <Button\\n android:id=\\"@+id/plusOneBtn\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"wrap_content\\"\\n android:layout_gravity=\\"center_horizontal\\"\\n android:text=\\"Plus One\\" />\\n</LinearLayout>\\n\\n
最后,在 MainActivity
中实现计数器的逻辑。
class MainActivity : AppCompatActivity() {\\n\\n private lateinit var viewModel: MainViewModel\\n private lateinit var binding: ActivityMainBinding\\n\\n override fun onCreate(savedInstanceState: Bundle?) {\\n super.onCreate(savedInstanceState)\\n binding = ActivityMainBinding.inflate(layoutInflater)\\n setContentView(binding.root)\\n\\n // 通过 ViewModelProvider 获取 ViewModel 实例\\n viewModel = ViewModelProvider(this)[MainViewModel::class.java]\\n\\n binding.plusOneBtn.setOnClickListener {\\n viewModel.counter++\\n refreshCounter()\\n }\\n refreshCounter()\\n }\\n\\n /**\\n * 更新当前的计数\\n */\\n private fun refreshCounter() {\\n binding.infoText.text = \\"count: ${viewModel.counter}\\"\\n }\\n}\\n
\\n其中,我们使用了 ViewModelProvider(this)[MainViewModel::class.java]
这行代码来获取 ViewModel 实例,那为什么不能直接创建 ViewModel 的实例呢?
因为如果我们直接在 onCreate()
方法中创建 ViewModel 实例,那么每次当 onCreate()
方法被调用时(比如因屏幕旋转导致Activity 重新创建),都会获取一个新的 MainViewModel
实例。这样,上一次实例的数据就会丢失,使得 ViewModel 失去了在配置变更中保存数据的核心意义。
而 ViewModelProvider(this)
会创建一个与 Activity 生命周期绑定的 Provider。
如果你是第一次请求,它会创建一个新的 ViewModel 实例,并保存起来,以便后续使用;
\\n如果不是第一次,它会返回存在的旧实例。
\\n运行程序,并点击界面中的按钮,即可增加计数:
\\n即使屏幕发生旋转,界面中的数据也不会丢失。
\\n其实,我们可以使用 activity-ktx
库中的属性委托 by viewModels()
来简化上述代码。
首先,添加依赖:
\\n// build.gradle.kts\\ndependencies {\\n implementation(\\"androidx.activity:activity-ktx:1.10.1\\")\\n}\\n
\\n然后,修改 MainActivity
。代码如下:
class MainActivity : AppCompatActivity() {\\n \\n private lateinit var binding: ActivityMainBinding\\n\\n // 使用属性委托声明\\n private val viewModel: MainViewModel by viewModels()\\n\\n override fun onCreate(savedInstanceState: Bundle?) {\\n super.onCreate(savedInstanceState)\\n binding = ActivityMainBinding.inflate(layoutInflater)\\n setContentView(binding.root)\\n\\n binding.plusOneBtn.setOnClickListener {\\n viewModel.counter++\\n refreshCounter()\\n }\\n refreshCounter()\\n }\\n\\n /**\\n * 更新当前的计数\\n */\\n private fun refreshCounter() {\\n binding.infoText.text = \\"count: ${viewModel.counter}\\"\\n }\\n}\\n
\\n怎么向 ViewModel 传递一些参数呢?这需要用到 ViewModelProvider.Factory
。现在,我们来实现即使在退出应用后重新打开的情况下,数据也不会丢失。
首先,给 MainViewModel
添加一个主构造函数,并带有一个 countReserved
参数,表示之前保存的数据。
class MainViewModel(countReserved: Int) : ViewModel() {\\n var counter = countReserved\\n}\\n
\\n然后,创建 MainViewModelFactory
类,实现 ViewModelProvider.Factory
接口。
class MainViewModelFactory(private val countReserved: Int) : ViewModelProvider.Factory {\\n\\n override fun <T : ViewModel> create(modelClass: Class<T>): T {\\n return MainViewModel(countReserved) as T\\n }\\n}\\n
\\n这个 Factory 的 create()
方法是用于指定创建 ViewModel 实例的方式的。
另外,在布局中添加一个“清零”按钮,用于清零计数器。
\\n<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>\\n<LinearLayout xmlns:android=\\"http://schemas.android.com/apk/res/android\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"match_parent\\"\\n android:gravity=\\"center\\"\\n android:orientation=\\"vertical\\"\\n android:padding=\\"16dp\\">\\n\\n <TextView\\n android:id=\\"@+id/infoText\\"\\n android:layout_width=\\"wrap_content\\"\\n android:layout_height=\\"wrap_content\\"\\n android:layout_gravity=\\"center_horizontal\\"\\n android:textSize=\\"32sp\\" />\\n\\n <Button\\n android:id=\\"@+id/plusOneBtn\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"wrap_content\\"\\n android:layout_marginTop=\\"24dp\\"\\n android:text=\\"Plus One\\" />\\n\\n <Button\\n android:id=\\"@+id/clearBtn\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"wrap_content\\"\\n android:layout_marginTop=\\"8dp\\"\\n android:text=\\"Clear\\" />\\n</LinearLayout>\\n
\\n最后,修改 MainActivity
:
class MainActivity : AppCompatActivity() {\\n\\n private lateinit var viewModel: MainViewModel\\n private lateinit var binding: ActivityMainBinding\\n private lateinit var sp: SharedPreferences\\n\\n override fun onCreate(savedInstanceState: Bundle?) {\\n super.onCreate(savedInstanceState)\\n binding = ActivityMainBinding.inflate(layoutInflater)\\n setContentView(binding.root)\\n\\n // 恢复之前的数据\\n sp = getPreferences(Context.MODE_PRIVATE)\\n val countReserved = sp.getInt(\\"count_reserved\\", 0)\\n\\n // 创建我们自定义的 Factory 实例\\n val factory = MainViewModelFactory(countReserved)\\n\\n // 将 Factory 传给 ViewModelProvider 来获取 ViewModel 实例\\n viewModel = ViewModelProvider(this, factory)[MainViewModel::class.java]\\n\\n binding.plusOneBtn.setOnClickListener {\\n viewModel.counter++\\n refreshCounter()\\n }\\n\\n binding.clearBtn.setOnClickListener {\\n viewModel.counter = 0\\n refreshCounter()\\n }\\n\\n refreshCounter()\\n }\\n\\n private fun refreshCounter() {\\n binding.infoText.text = \\"count: ${viewModel.counter}\\"\\n }\\n\\n override fun onPause() {\\n super.onPause()\\n // 保存当前的数据\\n sp.edit {\\n putInt(\\"count_reserved\\", viewModel.counter)\\n }\\n }\\n}\\n
\\n可以看到,当需要传递参数时,只需在创建 ViewModelProvider
时,将自定义的 Factory 实例传入第二个参数即可。它会使用这个 Factory 来创建 MainViewModel
实例。
运行程序,退出应用并重新打开,计数值是不会丢失的。
\\n同样地,属性委托也支持 Factory 模式,如下:
\\nclass MainActivity : AppCompatActivity() {\\n\\n private val viewModel: MainViewModel by viewModels {\\n // 恢复之前的数据\\n sp = getPreferences(Context.MODE_PRIVATE)\\n val countReserved = sp.getInt(\\"count_reserved\\", 0)\\n // 需要返回我们自定义的 Factory 实例\\n MainViewModelFactory(countReserved)\\n }\\n\\n private lateinit var binding: ActivityMainBinding\\n private lateinit var sp: SharedPreferences\\n\\n override fun onCreate(savedInstanceState: Bundle?) {\\n super.onCreate(savedInstanceState)\\n binding = ActivityMainBinding.inflate(layoutInflater)\\n setContentView(binding.root)\\n\\n binding.plusOneBtn.setOnClickListener {\\n viewModel.counter++\\n refreshCounter()\\n }\\n\\n binding.clearBtn.setOnClickListener {\\n viewModel.counter = 0\\n refreshCounter()\\n }\\n\\n refreshCounter()\\n }\\n\\n private fun refreshCounter() {\\n binding.infoText.text = \\"count: ${viewModel.counter}\\"\\n }\\n\\n override fun onPause() {\\n super.onPause()\\n // 保存当前的数据\\n sp.edit {\\n putInt(\\"count_reserved\\", viewModel.counter)\\n }\\n }\\n}\\n
","description":"Jetpack 简介 Jetpack 是 Google 推出的一套库、工具和指南的集合,旨在帮助开发者轻松构建 Android 应用。它的主要目的是:\\n\\n将 Android 开发的最佳实践内建在组件中;提供简洁的 API 减少重复性代码;提供向后兼容的组件,组件可以在任何 Android 系统版本上运行。\\n\\n我们新建一个名为 JetpackTest 的 Empty Views Activity 项目,就可以开始学习了。\\n\\nViewModel\\n\\nViewModel 是 Jetpack 中最重要的组件之一。因为在传统的开发模式下,Activity 的任务太重了…","guid":"https://juejin.cn/post/7522090635908218918","author":"雨白","authorUrl":null,"authorAvatar":null,"insertedAt":"2025-07-01T14:52:47.458Z","publishedAt":"2025-07-01T14:25:07.968Z","media":[{"url":"https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/e426b09676d843e690a5cc703ca382fc~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg6Zuo55m9:q75.awebp?rk3s=f64ab15b&x-expires=1751984707&x-signature=MLdBFs0gv7M8%2FARkUVe8qnvgU%2Fg%3D","type":"photo","width":0,"height":0,"blurhash":""},{"url":"https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/3384ca60c2a64da2a87bc24f17012279~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg6Zuo55m9:q75.awebp?rk3s=f64ab15b&x-expires=1751984707&x-signature=U1QO%2Fxj39z1qE7L509feLOUqTvs%3D","type":"photo","width":0,"height":0,"blurhash":""},{"url":"https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/7217093e291d4a3cb51aa32971daa4eb~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg6Zuo55m9:q75.awebp?rk3s=f64ab15b&x-expires=1751984707&x-signature=6Oj4P%2F%2BJ4kj%2B03rBWG%2BV2HpA7K0%3D","type":"photo","width":0,"height":0,"blurhash":""}],"categories":null,"attachments":null,"extra":null,"language":null,"feeds":{"type":"feed","id":"51863779246769152","url":"rsshub://juejin/category/android","title":"掘金 Android","description":"掘金 Android - Powered by RSSHub","siteUrl":"https://juejin.cn/android","image":null,"errorMessage":null,"errorAt":null,"ownerUserId":null}},{"feedId":"51863779246769152","id":"162864150893619200","title":"Android 13、14 和 15 的主要新特性","url":"https://juejin.cn/post/7521928562011127823","content":"主题色彩:
\\n照片选择器:
\\n多语言支持:
\\n改进的通知权限:
\\n蓝牙 LE 音频:
\\n更强的隐私控制:
\\n应用图标:
\\n文本缩放:
\\n更好的健康监测:
\\n系统 UI 改进:
\\n更高级的 AI 集成:
\\n新手势和用户界面改进:
\\n增强的多任务处理:
\\n安全性增强:
\\n更好的电池管理:
\\n书接上回...
\\n现在,我们的标题栏虽然是 Toolbar
,但它看起来和传统的 ActionBar
并没有什么区别。它只能随着 RecyclerView
列表的滚动而滚动。实际上,我们可以定制标题栏的样式。
接下来,我们就借助 CollapsingToolbarLayout
来实现一个可折叠的标题栏。
CollapsingToolbarLayout
是一个由 Material 库提供的布局,它可以丰富 Toolbar
的效果。我们来看看它的基本用法。
\\n\\n\\n
CollapsingToolbarLayout
并不能单独存在,它必须作为AppBarLayout
的直接子布局。而AppBarLayout
又必须是CoordinatorLayout
的子布局
我们开始实现。首先,创建一个 FruitActivity
作为水果详情界面,其布局名为 activity_fruit.xml
。
先来实现标题栏部分的布局。
\\n首先,使用 CoordinatorLayout
作为最外层布局。它可协调其子 View
的动作。
<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>\\n<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android=\\"http://schemas.android.com/apk/res/android\\"\\n xmlns:app=\\"http://schemas.android.com/apk/res-auto\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"match_parent\\">\\n \\n</androidx.coordinatorlayout.widget.CoordinatorLayout>\\n
\\n其中定义了 xmlns:app
命名空间。
然后,在 CoordinatorLayout
中放置一个 AppBarLayout
,在 AppBarLayout
中放置一个 CollapsingToolbarLayout
:
<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>\\n<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android=\\"http://schemas.android.com/apk/res/android\\"\\n xmlns:app=\\"http://schemas.android.com/apk/res-auto\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"match_parent\\">\\n\\n <com.google.android.material.appbar.AppBarLayout\\n android:id=\\"@+id/appBar\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"250dp\\">\\n\\n <com.google.android.material.appbar.CollapsingToolbarLayout\\n android:id=\\"@+id/collapsingToolbar\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"match_parent\\"\\n android:theme=\\"@style/ThemeOverlay.Material3.Dark.ActionBar\\"\\n app:contentScrim=\\"?attr/colorPrimary\\"\\n app:layout_scrollFlags=\\"scroll|exitUntilCollapsed\\">\\n\\n </com.google.android.material.appbar.CollapsingToolbarLayout>\\n\\n </com.google.android.material.appbar.AppBarLayout>\\n\\n</androidx.coordinatorlayout.widget.CoordinatorLayout>\\n
\\n其中 app:contentScrim
属性用于指定当 CollapsingToolbarLayout
折叠成普通标题栏时显示的背景色。
app:layout_scrollFlags
属性用于指定 AppBarLayout
响应滚动事件的逻辑。scroll
表示可滚动,没有这个标志其他的标志都不生效。exitUntilCollapsed
表示 AppBarLayout
会随着内容的滚动而向上滚动,直到完全折叠,然后固定在顶部。
现在,我们在 CollapsingToolbarLayout
中添加标题栏内容:一张图片和一个普通的 Toolbar
。
<ImageView\\n android:id=\\"@+id/fruitImageView\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"match_parent\\"\\n android:contentDescription=\\"@string/fruit_image_description\\"\\n android:scaleType=\\"centerCrop\\"\\n app:layout_collapseMode=\\"parallax\\" />\\n\\n<androidx.appcompat.widget.Toolbar\\n android:id=\\"@+id/toolbar\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"?attr/actionBarSize\\"\\n app:layout_collapseMode=\\"pin\\" />\\n
\\n其中的 app:layout_collapseMode
属性是用于指定 CollapsingToolbarLayout
内部的子 View
在折叠过程中的行为。
pin
表示在折叠过程中,Toolbar
的位置始终保持不变,“钉”在内容界面的顶部。
parallax
表示在折叠过程中,图片会比内容滚动得更慢进行移动,增加视觉层次感。
下面实现内容详情部分的布局。
\\n我们在 CoordinatorLayout
中,AppBarLayout
的下方(同层级),添加一个 NestedScrollView
。
<androidx.core.widget.NestedScrollView\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"match_parent\\"\\n app:layout_behavior=\\"@string/appbar_scrolling_view_behavior\\">\\n\\n <LinearLayout\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"wrap_content\\"\\n android:orientation=\\"vertical\\">\\n\\n <com.google.android.material.card.MaterialCardView\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"wrap_content\\"\\n android:layout_marginLeft=\\"15dp\\"\\n android:layout_marginTop=\\"35dp\\"\\n android:layout_marginRight=\\"15dp\\"\\n android:layout_marginBottom=\\"15dp\\"\\n app:cardCornerRadius=\\"4dp\\">\\n\\n <TextView\\n android:id=\\"@+id/fruitContentText\\"\\n android:layout_width=\\"wrap_content\\"\\n android:layout_height=\\"wrap_content\\"\\n android:layout_margin=\\"10dp\\" />\\n </com.google.android.material.card.MaterialCardView>\\n\\n </LinearLayout>\\n</androidx.core.widget.NestedScrollView>\\n
\\n这样 AppBarLayout
(标题栏)便能响应 NestedScrollView
的滚动,这是因为我们通过 app:layout_behavior
属性给 NestedScrollView
指定了一个 Behavior
。它会在 NestedScrollView
滚动时,让 CoordinatorLayout
捕获滚动事件并通知给 AppBarLayout
做出响应。
最后,我们添加一个表示评论的悬浮按钮,它也能和 CoordinatorLayout
协作。
<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>\\n<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android=\\"http://schemas.android.com/apk/res/android\\"\\n xmlns:app=\\"http://schemas.android.com/apk/res-auto\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"match_parent\\">\\n\\n ...\\n\\n <com.google.android.material.floatingactionbutton.FloatingActionButton\\n android:layout_width=\\"wrap_content\\"\\n android:layout_height=\\"wrap_content\\"\\n android:layout_margin=\\"16dp\\"\\n android:contentDescription=\\"评论\\"\\n android:src=\\"@drawable/ic_comment\\"\\n app:layout_anchor=\\"@id/appBar\\"\\n app:layout_anchorGravity=\\"bottom|end\\" />\\n\\n</androidx.coordinatorlayout.widget.CoordinatorLayout>\\n
\\n其中使用了 app:layout_anchor
属性设置了一个锚点在 appBar
上,并使用 app:layout_anchorGravity
属性进行定位。这样 CoordinatorLayout
能够调度悬浮按钮的行为,让其根据 appBar
的位置变化自动调整自身的位置,从而始终处于标题栏区域的右下角。
水果详情页的布局编写完后,我们现在来 FruitActivity
中实现逻辑。
class FruitActivity : AppCompatActivity() {\\n\\n companion object {\\n const val FRUIT_NAME = \\"fruit_name\\"\\n const val FRUIT_IMAGE_ID = \\"fruit_image_id\\"\\n }\\n\\n private lateinit var binding: ActivityFruitBinding\\n\\n override fun onCreate(savedInstanceState: Bundle?) {\\n super.onCreate(savedInstanceState)\\n binding = ActivityFruitBinding.inflate(layoutInflater)\\n setContentView(binding.root)\\n\\n\\n // 设置操作栏\\n setSupportActionBar(binding.toolbar)\\n // 显示 Home 按钮,默认为返回箭头\\n supportActionBar?.setDisplayHomeAsUpEnabled(true)\\n\\n // 获取传入的水果名称和水果图片资源 id\\n val fruitName = intent.getStringExtra(FRUIT_NAME) ?: \\"\\"\\n val fruitImageId = intent.getIntExtra(FRUIT_IMAGE_ID, 0)\\n \\n // 填充到界面中\\n binding.collapsingToolbar.title = fruitName\\n Glide.with(this).load(fruitImageId).into(binding.fruitImageView)\\n binding.fruitContentText.text = generateFruitContent(fruitName)\\n }\\n\\n override fun onOptionsItemSelected(item: MenuItem): Boolean {\\n when (item.itemId) {\\n // 点击了 Home 按钮\\n android.R.id.home -> {\\n finish()\\n return true\\n }\\n }\\n return super.onOptionsItemSelected(item)\\n }\\n\\n private fun generateFruitContent(fruitName: String) = fruitName.repeat(500)\\n}\\n
\\n为了能从列表中跳转到 FruitActivity
,我们需要给 RecyclerView
列表子项注册点击事件。
// FruitAdapter.kt\\nclass FruitAdapter :\\n ListAdapter<Fruit, FruitAdapter.ViewHolder>(FruitDiffCallback) {\\n\\n inner class ViewHolder(private val binding: FruitItemBinding) :\\n RecyclerView.ViewHolder(binding.root) {\\n init {\\n // 在 ViewHolder 初始化时设置监听器\\n binding.root.setOnClickListener {\\n val position = bindingAdapterPosition // 获取 ViewHolder 在适配器中的位置\\n // 检查 position 是否有效\\n if (position != RecyclerView.NO_POSITION) {\\n // 携带数据跳转到 FruitActivity\\n val fruit = getItem(position)\\n val intent = Intent(binding.root.context, FruitActivity::class.java).apply {\\n putExtra(FruitActivity.FRUIT_NAME, fruit.name)\\n putExtra(FruitActivity.FRUIT_IMAGE_ID, fruit.imageId)\\n }\\n binding.root.context.startActivity(intent)\\n }\\n }\\n }\\n\\n ...\\n }\\n\\n\\n ...\\n}\\n
\\n运行程序,点击任意一个水果,即可看到:
\\n虽然这样效果已经很不错了,但我们还可以让背景图片延伸到系统状态栏下方,实现沉浸式体验。
\\n首先,在 FruitActivity
的 onCreate()
方法中调用 enableEdgeToEdge()
方法。
override fun onCreate(savedInstanceState: Bundle?) {\\n super.onCreate(savedInstanceState)\\n // 让内容布局延伸到系统栏(状态栏、导航栏)\\n enableEdgeToEdge()\\n \\n ...\\n}\\n
\\n这样会导致一个问题:状态栏会遮挡 Toolbar
的部分区域。为此,我们需要在 FruitActivity
中添加如下内容:
class FruitActivity : AppCompatActivity() {\\n\\n ...\\n\\n // 防止 Insets 多次应用\\n private var insetsApplied = false\\n \\n override fun onCreate(savedInstanceState: Bundle?) {\\n ...\\n\\n ViewCompat.setOnApplyWindowInsetsListener(binding.appBar) { _, windowInsets ->\\n if (!insetsApplied) {\\n // 获取状态栏的高度\\n val statusBarHeight =\\n windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()).top\\n\\n // 增加 Toolbar 的物理高度,让它有足够空间\\n val toolbar = binding.toolbar\\n val originalToolbarHeight = toolbar.layoutParams.height\\n toolbar.layoutParams.height = originalToolbarHeight + statusBarHeight\\n\\n // 为 Toolbar 设置顶部内边距\\n toolbar.setPadding(\\n toolbar.paddingLeft,\\n statusBarHeight,\\n toolbar.paddingRight,\\n toolbar.paddingBottom\\n )\\n\\n // 为 CollapsingToolbarLayout 设置最小高度,以适应 Toolbar\\n binding.collapsingToolbar.minimumHeight = originalToolbarHeight + statusBarHeight\\n\\n // 标记为已处理,防止重复执行\\n insetsApplied = true\\n }\\n\\n // 返回原始 insets,不做消耗\\n windowInsets\\n }\\n\\n }\\n\\n ...\\n}\\n
\\n再次运行程序,你将看到:
\\n用一个 「左舵车变右舵车」 的改装车间故事 🛠️,结合代码,带你轻松搞懂Android车机右舵适配的核心逻辑!
\\n\\n\\n❓ 问题:左舵车机的空调控制条在左侧,右舵车应该移到右侧!
\\n
\\n🎯 解决方案:利用RTL(Right-To-Left)布局支持!
android:layoutDirection=\\"locale\\"
xml
\\nRun
\\n<!-- 在根布局声明支持RTL --\x3e\\n<LinearLayout\\n xmlns:android=\\"http://schemas.android.com/apk/res/android\\"\\n android:layout_width=\\"match_parent\\"\\n android:layout_height=\\"match_parent\\"\\n android:layoutDirection=\\"locale\\"> <!-- 关键!自动跟随系统语言方向 --\x3e\\n\\n <!-- 空调控制条:用start/end代替left/right --\x3e\\n <SeekBar\\n android:layout_width=\\"0dp\\"\\n android:layout_height=\\"wrap_content\\"\\n android:layout_weight=\\"1\\"\\n android:layout_marginStart=\\"16dp\\" <!-- 用start!不是left -->\\n android:layout_marginEnd=\\"16dp\\"/> <!-- 用end!不是right --\x3e\\n</LinearLayout>\\n
\\n\\n\\n“记住!右舵国家(如英语、阿拉伯语)系统默认开启RTL,用
\\nstart=右
,end=左
。
\\n禁止写死left/right
!否则右舵下界面会错乱!”
\\n\\n❓ 问题:左舵方向盘 “音量+” 键在右边,右舵车这个键却在左边!
\\n
\\n🎯 解决方案:动态判断驾驶位方向,重映射KeyEvent!
CarUserManager
+ 重写 onKeyEvent
java
\\npublic class MyCarInputService extends CarInputService {\\n // 关键:获取驾驶位位置(左舵 or 右舵)\\n private boolean isRightHandDrive() {\\n CarUserManager carUserManager = (CarUserManager) getCar().getCarManager(Car.CAR_USER_SERVICE);\\n return (carUserManager.getDriverSide() == CarUserManager.DRIVER_SIDE_RIGHT);\\n }\\n\\n @Override\\n public void onKeyEvent(KeyEvent event) {\\n int keyCode = event.getKeyCode();\\n if (isRightHandDrive()) { // 如果是右舵车\\n keyCode = remapKeyForRightDrive(keyCode); // 按键重映射!\\n }\\n // 处理按键事件...\\n super.onKeyEvent(event);\\n }\\n\\n private int remapKeyForRightDrive(int keyCode) {\\n switch (keyCode) {\\n case KeyEvent.KEYCODE_VOLUME_UP: \\n return KeyEvent.KEYCODE_VOLUME_DOWN; // 右舵下物理位置颠倒,功能对调\\n case KeyEvent.KEYCODE_DPAD_LEFT:\\n return KeyEvent.KEYCODE_DPAD_RIGHT; // 方向键左右镜像\\n // ... 其他按键\\n default:\\n return keyCode;\\n }\\n }\\n}\\n
\\n\\n\\n❓ 问题:左舵车导航箭头偏左,右舵车需让箭头偏右,否则导航箭头会被方向盘挡住!
\\n
\\n🎯 解决方案:动态计算驾驶员中心偏移量(Driver Offset)
java
\\n// 在导航地图View绘制时\\npublic class NavigationMapView extends MapView {\\n private float driverOffsetX = 0f;\\n\\n @Override\\n protected void onDraw(Canvas canvas) {\\n // 关键:判断是否右舵,计算偏移量\\n if (isRightHandDrive()) {\\n driverOffsetX = getWidth() * 0.15f; // 向右偏移15%宽度\\n } else {\\n driverOffsetX = -getWidth() * 0.15f; // 向左偏移15%\\n }\\n \\n canvas.save();\\n canvas.translate(driverOffsetX, 0); // 平移画布!\\n super.onDraw(canvas);\\n canvas.restore();\\n }\\n}\\n
\\n\\n\\n❓ 问题:某些图标/文字在右舵下需镜像(如“返回箭头”方向)
\\n
\\n🎯 解决方案:资源目录加-ldrtl
后缀(Layout Direction Right-To-Left)
text
\\nres/\\n ├── drawable/ \\n │ ├── ic_back.png # 左舵使用(箭头向左)\\n ├── drawable-ldrtl/ # 右舵自动匹配此目录!\\n │ ├── ic_back.png # 右舵专用(箭头向右!)\\n ├── layout/\\n │ ├── main_activity.xml # 通用布局\\n ├── layout-ldrtl/ # 右舵专属布局(如有特殊需求)\\n │ ├── main_activity.xml\\n
\\n\\n\\n✅ 系统在RTL语言环境下,自动加载
\\n-ldrtl
目录资源!
测试工具强制切RTL:
\\nshell
\\nadb shell setprop debug.force_rtl true\\n
\\n重启车机,立即看到右舵效果!(无需真车)
\\n检查所有 left/right
属性:
\\n用 Android Studio 的 “Refactor → Add RTL Support” 自动替换!
方向盘按键物理位置表:
\\njava
\\n// 建立“物理位置 → 逻辑功能”映射表\\nMap<Integer, Integer> keyMapping = new HashMap<>();\\nif (isRightHandDrive) {\\n keyMapping.put(PHYSICAL_RIGHT_BUTTON, LOGIC_VOLUME_UP);\\n} else {\\n keyMapping.put(PHYSICAL_LEFT_BUTTON, LOGIC_VOLUME_UP);\\n}\\n
\\n仪表盘/HUD也要镜像:
\\n使用 Canvas.scale(-1, 1)
实现水平翻转!
模块 | 左舵 → 右舵秘籍 | 代码关键点 |
---|---|---|
UI布局 | 用 start/end ,启用 layoutDirection=\\"locale\\" | 资源目录 -ldrtl |
方向盘按键 | 物理位置映射表 + 动态事件重定向 | CarUserManager + onKeyEvent |
导航/地图 | 动态计算驾驶员中心偏移量 | canvas.translate(offsetX, 0) |
图标/文字 | 提供RTL专用资源(如镜像图标) | drawable-ldrtl/ |
经过这番改造,你的车机系统完美适配右舵车!英国用户一上车:“导航箭头在右边!音量键位置也对!这车机真懂我!” 💂♂️🇬🇧
\\n\\n\\n记住:右舵不是翻译,是驾驶员视角的体验重构。
\\n
\\n只要抓住 「布局镜像」「按键重映射」「驾驶员居中偏移」 这三点,你就能通吃全球市场!🚘💨
需要具体某块代码(如HUD右舵适配),随时喊我! 😉
","description":"用一个 「左舵车变右舵车」 的改装车间故事 🛠️,结合代码,带你轻松搞懂Android车机右舵适配的核心逻辑! 🚗 故事背景:汽车改装厂\\n主角:你(新手技师 👨🔧)\\n导师:老王(改装大师 👴)\\n任务:把一辆 左舵(驾驶位在左) 的车机系统,完美适配到 右舵(驾驶位在右) 的车上出口英国!\\n难点:界面不能简单平移,布局、触摸逻辑、方向盘按键、驾驶员视角全都要镜像处理!\\n🛠️ 第一步:布局镜像(UI翻转)\\n\\n❓ 问题:左舵车机的空调控制条在左侧,右舵车应该移到右侧!\\n 🎯 解决方案:利用RTL(Right-To-Left)布局支持!…","guid":"https://juejin.cn/post/7521920528094232618","author":"用户201879283167","authorUrl":null,"authorAvatar":null,"insertedAt":"2025-07-01T09:40:56.848Z","publishedAt":"2025-07-01T09:38:04.798Z","media":null,"categories":["Android"],"attachments":null,"extra":null,"language":null,"feeds":{"type":"feed","id":"51863779246769152","url":"rsshub://juejin/category/android","title":"掘金 Android","description":"掘金 Android - Powered by RSSHub","siteUrl":"https://juejin.cn/android","image":null,"errorMessage":null,"errorAt":null,"ownerUserId":null}}],"analytics":{"listId":"67449434633867264","subscriptionCount":1}}')