本文涵盖 Compose 状态管理的全套方案:状态提升、ViewModel + StateFlow、CompositionLocal、SavedStateHandle、以及复杂场景下的架构选型。配合 Compose 四大核心概念 中的 State 章节阅读效果最佳。

状态管理全景图

本地状态(组件内部)
  mutableStateOf + remember
    ↓ 提升
状态提升(父子传参)
  状态在父组件,通过参数下发
    ↓ 跨页面
ViewModel(页面级)
  StateFlow + collectAsState,生命周期感知
    ↓ 跨组件
CompositionLocal(作用域共享)
  Theme、Context 等隐式传递
    ↓ 跨应用
持久化状态
  rememberSaveable / DataStore / Room
层级方案存活范围适用场景
组件内部remember + mutableStateOf组件在组合树中开关、展开/折叠、输入框
父子通信状态提升 (State Hoisting)父组件生命周期表单、列表项
页面级ViewModel + StateFlowActivity/Fragment 生命周期页面数据、网络请求结果
作用域共享CompositionLocalComposable 子树主题、导航栈、依赖注入
持久化rememberSaveable / DataStore进程存活 / 持久文件配置变更保留 / 设置、Token
全局单例 + StateFlowApplication用户信息、购物车

状态提升(State Hoisting)—— 最基本的心智模型

什么是状态提升

将状态从子组件「提升」到最近的共同父组件,子组件通过参数接收状态和事件回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ❌ 状态下沉:子组件自己管状态 → 父组件无法控制
@Composable
fun SearchField_Bad() {
var text by remember { mutableStateOf("") } // 锁死在内部
TextField(value = text, onValueChange = { text = it })
}

// ✅ 状态提升:父组件持有状态,子组件只负责展示
@Composable
fun SearchField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
placeholder = { Text("搜索…") }
)
}

// 父组件
@Composable
fun HomeScreen() {
var query by remember { mutableStateOf("") }

Column {
SearchField(value = query, onValueChange = { query = it })
// 父组件可以读取 query 做搜索 → 状态源唯一
Text("搜索:$query")
}
}

状态提升三原则

原则说明
单一数据源状态只有一个持有者,避免多处各自维护导致不一致
单向数据流状态向下传递(参数),事件向上冒泡(回调)
不可变性参数用 val / data class,接收方不修改传入的状态

提升到什么层级?

1
2
3
4
① 兄弟组件间共享       → 提升到共同父组件
② 多页面间共享 → 提升到 ViewModel
③ 整个子树隐式共享 → 用 CompositionLocal
④ 全局(登录态等) → 单例 + StateFlow

🔑 状态提升是 Compose 的通用模式,几乎所有官方 Material 组件都遵循此模式(如 TextFieldCheckboxSwitch)。

ViewModel + StateFlow —— 页面级状态管理(推荐)

为什么需要 ViewModel

remember 只能在 Composable 中存活。一旦页面被销毁重建(屏幕旋转、进程重启),状态就丢了。ViewModel 绑定到 Activity/Fragment 的生命周期,可以在配置变更后存活

1
2
3
// build.gradle.kts
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")

标准模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// ① 定义 UI 状态 —— 用一个 data class 聚合所有状态
data class HomeUiState(
val isLoading: Boolean = true,
val items: List<String> = emptyList(),
val errorMessage: String? = null,
val searchQuery: String = ""
)

// ② ViewModel —— 用 MutableStateFlow 管理状态
class HomeViewModel : ViewModel() {

private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

init {
loadData()
}

fun onSearchQueryChange(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}

fun refresh() {
_uiState.update { it.copy(isLoading = true) }
loadData()
}

private fun loadData() {
viewModelScope.launch {
try {
// 模拟网络请求
val data = fetchItems()
_uiState.update { it.copy(
isLoading = false,
items = data,
errorMessage = null
)}
} catch (e: Exception) {
_uiState.update { it.copy(
isLoading = false,
errorMessage = e.message
)}
}
}
}
}

// ③ Composable —— collectAsState 收集状态
@Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()

when {
uiState.isLoading -> LoadingIndicator()
uiState.errorMessage != null -> ErrorView(uiState.errorMessage!!) {
viewModel.refresh()
}
else -> ContentList(
items = uiState.items,
query = uiState.searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
onRefresh = viewModel::refresh
)
}
}

为什么用 StateFlow 而不是 LiveData

对比StateFlowLiveData
类型安全✅ 编译时检查⚠️ 运行时检查
初始值✅ 必须有初始值❌ 可选
线程✅ 不限线程⚠️ 仅主线程 setValue
CollectcollectAsState()observeAsState()
Flow 操作符mapcombinefilter❌ 需要 Transformations
测试✅ 直接 runTest collect⚠️ 需要 InstantTaskExecutorRule

Google 官方推荐 StateFlow 作为 Compose 状态容器的首选

单状态对象 vs 多状态对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ❌ 分散管理 —— 难以追踪状态,容易漏更新
class BadViewModel : ViewModel() {
val isLoading = MutableStateFlow(false)
val items = MutableStateFlow<List<String>>(emptyList())
val error = MutableStateFlow<String?>(null)
// 每次都要单独更新,UI 要收集 3 个 Flow
}

// ✅ 单一 data class —— 原子更新,UI 只需收集一个 Flow
data class HomeUiState(
val isLoading: Boolean = false,
val items: List<String> = emptyList(),
val error: String? = null
)

class GoodViewModel : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

// copy() 保证原子更新,不会出现中间态
fun onLoaded(data: List<String>) {
_uiState.update { it.copy(isLoading = false, items = data, error = null) }
}
}

🔑 一条原则:一个 ViewModel → 一个 data class UiState → 一个 StateFlow

SavedStateHandle —— 进程被杀死后恢复

ViewModel 可以存活配置变更但无法存活进程被杀。SavedStateHandle 解决了这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class SearchViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {

// ① 读写简单键值对(自动持久化到 Bundle)
var query by mutableStateOf(
savedStateHandle.get<String>("query") ?: ""
)
private set

fun onQueryChange(newQuery: String) {
query = newQuery
savedStateHandle["query"] = newQuery // ← 自动持久化
}

// ② StateFlow 方式
val selectedTab = savedStateHandle.getStateFlow("tab", 0)

// ③ 复杂对象(需要序列化)
fun saveForm(form: FormData) {
savedStateHandle["form_json"] = Json.encodeToString(FormData.serializer(), form)
}
fun restoreForm(): FormData {
val json = savedStateHandle.get<String>("form_json") ?: return FormData()
return Json.decodeFromString(FormData.serializer(), json)
}
}
场景方案
配置变更(旋转屏幕)ViewModel(默认)
进程被杀死后恢复SavedStateHandle
跨进程 / 长期保存DataStore / Room

CompositionLocal —— 隐式跨组件共享

当状态需要在整个子树中共享,但又不想逐层手动传递时,使用 CompositionLocal

典型场景

  • MaterialTheme.colors 统一颜色
  • LocalContext.current 获取 Context
  • 自定义主题、语言、导航栈

自定义 CompositionLocal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ① 定义
data class AppTheme(
val isDark: Boolean = false,
val accentColor: Color = Color.Blue
)

val LocalAppTheme = staticCompositionLocalOf { AppTheme() }

// ② 提供
@Composable
fun AppThemeProvider(
isDark: Boolean,
content: @Composable () -> Unit
) {
val theme = if (isDark) {
AppTheme(isDark = true, accentColor = Color(0xFFBB86FC))
} else {
AppTheme()
}
CompositionLocalProvider(LocalAppTheme provides theme) {
content()
}
}

// ③ 消费
@Composable
fun ThemedButton() {
val theme = LocalAppTheme.current
Button(
colors = ButtonDefaults.buttonColors(
containerColor = theme.accentColor
)
) {
Text("按钮")
}
}

compositionLocalOf vs staticCompositionLocalOf

compositionLocalOfstaticCompositionLocalOf
重组范围仅用到 .current 的组件所有组件
性能值变化频繁时更优值几乎不变时更优
典型用途动态主题色、滚动位置Context、主题类型、导航控制器

⚠️ 不要用 CompositionLocal 替代参数传递。只有当跨越多层、逐层传参显得冗余时才使用。

复杂状态管理方案对比

当应用状态越来越复杂(多个 ViewModel 间共享数据、缓存、跨页面通信),可以考虑更结构化的方案:

方案特点适合
ViewModel + StateFlow官方原生,零额外依赖⭐ 中小型项目首选
MVI(Model-View-Intent)单向数据流,Action → State → UI复杂 UI,需要严格状态管理
unidirectional data flow (UDF)Google 官方推荐架构配合 ViewModel 的自然延伸
MoleculeCompose 运行时驱动状态流复杂业务逻辑用 Compose 表达
Store(Circuit/Mobius)状态机驱动,支持 effect 中间件大型应用,团队协作

MVI 核心结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// ① 状态 —— 不可变 data class
data class CounterState(val count: Int = 0, val isLoading: Boolean = false)

// ② 事件 / Intent —— 用户操作
sealed interface CounterEvent {
data object Increment : CounterEvent
data object Decrement : CounterEvent
data object Reset : CounterEvent
data class AsyncIncrement(val after: Long) : CounterEvent
}

// ③ ViewModel —— 处理事件,产生新状态
class CounterViewModel : ViewModel() {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state.asStateFlow()

fun onEvent(event: CounterEvent) {
when (event) {
CounterEvent.Increment -> _state.update { it.copy(count = it.count + 1) }
CounterEvent.Decrement -> _state.update { it.copy(count = it.count - 1) }
CounterEvent.Reset -> _state.update { it.copy(count = 0) }
// 异步操作
is CounterEvent.AsyncIncrement -> {
_state.update { it.copy(isLoading = true) }
viewModelScope.launch {
delay(event.after)
_state.update { it.copy(count = it.count + 1, isLoading = false) }
}
}
}
}
}

// ④ UI —— 发送事件,展示状态
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.state.collectAsState()

Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("计数:${state.count}", style = MaterialTheme.typography.headlineMedium)

if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(8.dp))
}

Row {
Button(onClick = { viewModel.onEvent(CounterEvent.Increment) }) { Text("+1") }
Button(onClick = { viewModel.onEvent(CounterEvent.Decrement) }) { Text("-1") }
Button(onClick = { viewModel.onEvent(CounterEvent.Reset) }) { Text("重置") }
}

Button(onClick = {
viewModel.onEvent(CounterEvent.AsyncIncrement(2000))
}) {
Text("2秒后 +1")
}
}
}

MVI 的优点

优点说明
可追溯每个状态变更都源自一个 Event,方便调试和回放
可测试ViewModel 是纯函数:(State, Event) → State
线程安全MutableStateFlow.update {} 原子操作
单向数据流UI → Event → ViewModel → State → UI

全局状态 —— 跨 Activity 共享

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 单例 + StateFlow 实现全局购物车
object CartManager {
private val _items = MutableStateFlow<List<CartItem>>(emptyList())
val items: StateFlow<List<CartItem>> = _items.asStateFlow()

val totalCount: StateFlow<Int> = _items
.map { it.sumOf { item -> item.quantity } }
.stateIn(GlobalScope, SharingStarted.WhileSubscribed(), 0)

val totalPrice: StateFlow<Double> = _items
.map { it.sumOf { item -> item.price * item.quantity } }
.stateIn(GlobalScope, SharingStarted.WhileSubscribed(), 0.0)

fun addItem(item: CartItem) {
_items.update { list ->
val idx = list.indexOfFirst { it.id == item.id }
if (idx >= 0) {
list.toMutableList().also {
it[idx] = it[idx].copy(quantity = it[idx].quantity + 1)
}
} else {
list + item
}
}
}

fun removeItem(id: String) {
_items.update { it.filter { item -> item.id != id } }
}

fun clear() {
_items.value = emptyList()
}
}

// 在任何 Composable 中使用
@Composable
fun CartBadge() {
val count by CartManager.totalCount.collectAsState()
Badge { Text("$count") }
}

⚠️ 全局单例要慎用:测试难隔离、生命周期不可控。优先考虑 ViewModel + 依赖注入(Hilt/Koin)。

性能优化

状态粒度 —— 越细越好

1
2
3
4
5
6
7
8
9
// ❌ 一个大状态 → 任何字段变化都触发所有 UI 重组
data class BigState(val a: Int, val b: Int, val c: Int, val d: Int)

Row {
Text("a: ${state.a}") // a 变了,全部重组
Text("b: ${state.b}")
Text("c: ${state.c}")
Text("d: ${state.d}")
}
1
2
3
4
5
6
7
8
9
10
11
12
// ✅ 细粒度状态 → 只重组需要的地方
val a by viewModel.flowA.collectAsState()
val b by viewModel.flowB.collectAsState()
val c by viewModel.flowC.collectAsState()
val d by viewModel.flowD.collectAsState()

Row {
Text("a: $a") // 只有 a 变化时这里才重组
Text("b: $b") // 只有 b 变化时这里才重组
Text("c: $c")
Text("d: $d")
}

🔑 权衡:单一 StateFlow 方便管理,但可能导致不必要的重组。如果页面复杂且有独立的 UI 区域,拆分为多个 StateFlow。

derivedStateOf —— 缓存计算结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Composable
fun ItemList(viewModel: ItemViewModel = viewModel()) {
val items by viewModel.items.collectAsState()
val filter by viewModel.filter.collectAsState()

// ✅ derivedStateOf:仅 items 或 filter 变化时重新计算
val filteredItems by remember {
derivedStateOf {
items.filter { it.matches(filter) }
}
}

LazyColumn {
items(filteredItems, key = { it.id }) { item ->
ItemRow(item)
}
}
}

derivedStateOf 内部会做相等性比较,只有结果变化才通知重组。

常见坑与最佳实践

原因解决
旋转屏幕状态丢失用了 remember 没用 ViewModel页面级状态用 ViewModel
ViewModel 中创建 State 导致内存泄漏在 ViewModel init 里持有 Composable 引用ViewModel 只管数据,不管 UI
collectAsState() 阻塞主线程默认在主线程 collectFlow 内部用 flowOn(Dispatcher.IO)
多个 ViewModel 间状态不同步各自维护同一份数据提取到 Repository 或共享 StateFlow
CompositionLocal 导致全树重组使用了 compositionLocalOf 且值频繁变化staticCompositionLocalOf
StateFlow 热流导致后台持续计算WhileSubscribed(5000) 默认永不停设置合适的 stopTimeoutMillis
mutableStateOf 放错位置放在 ViewModel 但不用 collectAsStateViewModel 中用 StateFlow,Composable 中用 mutableStateOf

架构速查表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
┌─────────────────────────────────────────────────────┐
│ Activity / Fragment │
│ ┌───────────────────────────────────────────────┐ │
│ │ setContent { AppTheme { NavHost { ... } } } │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │Screen A │ │ Screen B │ │ Screen C │ │
│ │ │ │ │ │ │ │
│ │viewModel │ │ viewModel │ │ viewModel │ │
│ │ .state │ │ .state │ │ .state │ │
│ │ ↓ │ │ ↓ │ │ ↓ │ │
│ │collectA- │ │ collectA- │ │ collectA- │ │
│ │sState() │ │ sState() │ │ sState() │ │
│ │ ↓ │ │ ↓ │ │ ↓ │ │
│ │ UI ← │ │ UI ← │ │ UI ← │ │
│ │ Event │ │ Event │ │ Event │ │
│ └──────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Repository Layer (StateFlow) │ │
│ │ ┌────────────┐ ┌──────────────────────┐ │ │
│ │ │CartManager │ │ UserSessionManager │ │ │
│ │ │(Singleton) │ │ (Singleton) │ │ │
│ │ └────────────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Data Layer (Room / DataStore / Retrofit) │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘

完整实战:搜索 + 收藏功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// ====== UiState ======
data class SearchUiState(
val query: String = "",
val results: List<Repo> = emptyList(),
val favorites: Set<String> = emptySet(),
val isLoading: Boolean = false,
val error: String? = null
)

// ====== Event ======
sealed interface SearchEvent {
data class QueryChanged(val query: String) : SearchEvent
data object Search : SearchEvent
data class ToggleFavorite(val repoId: String) : SearchEvent
}

// ====== ViewModel ======
class SearchViewModel(
private val repository: RepoRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {

private val _state = MutableStateFlow(SearchUiState())
val state: StateFlow<SearchUiState> = _state.asStateFlow()

init {
// 恢复进程杀死前的查询
savedStateHandle.get<String>("query")?.let { query ->
_state.update { it.copy(query = query) }
}
}

fun onEvent(event: SearchEvent) {
when (event) {
is SearchEvent.QueryChanged -> {
_state.update { it.copy(query = event.query) }
savedStateHandle["query"] = event.query
}

SearchEvent.Search -> {
val query = _state.value.query.ifBlank { return }
_state.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
try {
val results = repository.search(query)
_state.update { it.copy(isLoading = false, results = results) }
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}

is SearchEvent.ToggleFavorite -> {
_state.update { state ->
val newFavorites = state.favorites.toMutableSet().apply {
if (contains(event.repoId)) remove(event.repoId)
else add(event.repoId)
}
state.copy(favorites = newFavorites)
}
}
}
}
}

// ====== UI ======
@Composable
fun SearchScreen(
viewModel: SearchViewModel = viewModel(),
onRepoClick: (String) -> Unit
) {
val state by viewModel.state.collectAsState()

Scaffold(
topBar = {
SearchBar(
query = state.query,
onQueryChange = { viewModel.onEvent(SearchEvent.QueryChanged(it)) },
onSearch = { viewModel.onEvent(SearchEvent.Search) }
)
}
) { padding ->
when {
state.isLoading -> Box(
Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }

state.error != null -> ErrorRetry(state.error!!) {
viewModel.onEvent(SearchEvent.Search)
}

state.results.isEmpty() && state.query.isNotEmpty() -> EmptyResult()

else -> LazyColumn(modifier = Modifier.padding(padding)) {
items(state.results, key = { it.id }) { repo ->
RepoItem(
repo = repo,
isFavorite = repo.id in state.favorites,
onFavorite = { viewModel.onEvent(SearchEvent.ToggleFavorite(repo.id)) },
onClick = { onRepoClick(repo.id) }
)
}
}
}
}
}

以上模式可以无痛扩展到任意页面:定义 UiState → 定义 Event → ViewModel 处理 Event 产生新 State → UI 收集。

Material 3 完全指南

Material 3(Material You)是 Google 最新的设计语言,强调个性化、动态色彩和更灵活的组件系统。本文覆盖 M3 的核心概念、主题系统和全部常用组件的使用方式。


一、快速对比:M2 vs M3

维度Material 2Material 3
色彩固定配色,Primary/Secondary动态取色(Dynamic Color),按色相-Tonal Palette 生成
排版固定 13 个 text style更灵活的 Display/Headline/Title/Body/Label 体系
形状固定 3 级圆角更细粒度的 7 级圆角
组件后缀大部分以 Material 前缀统一以 Material3 包区分
暗色模式手动配置Surface 自动分层(surfaceColorAtElevation)
TopAppBarTopAppBar()TopAppBar()(参数更丰富,如 scrollBehavior
NavigationBottomNavigationNavigationBar + NavigationBarItem

二、依赖与入口

1
2
3
4
5
6
// build.gradle.kts (Module)
dependencies {
implementation("androidx.compose.material3:material3:1.3.1")
// Material Icons Extended(可选,获取更多图标)
implementation("androidx.compose.material:material-icons-extended:1.7.6")
}

MaterialTheme 入口:所有 M3 组件需要在 MaterialTheme 上下文中使用:

1
2
3
4
5
6
7
MaterialTheme(
colorScheme = lightColorScheme(),
typography = Typography(),
shapes = Shapes()
) {
// Your UI content
}

三、颜色系统 —— ColorScheme

3.1 色槽体系(Tonal Palette)

M3 不再使用 Primary/Secondary/Surface 的固定值,而是一组按色相 + 亮度级别生成的色槽:

角色用途
primary主色,用于 FAB、强调按钮、高亮
onPrimary主色上的内容色(通常是白/黑色)
primaryContainer主色的浅色容器,如选中背景
onPrimaryContainer容器上的内容色
secondary辅助色
tertiary第三色,用于强调对比
background / onBackground应用背景
surface / onSurface卡片、弹窗等表面色
surfaceVariant / onSurfaceVariant表面色的变体(如卡片描边、辅助文字)
error / onError / errorContainer / onErrorContainer错误色
outline / outlineVariant轮廓线颜色
inverseSurface / inverseOnSurface反色(Snackbar 常用)
scrim遮罩层颜色(Dark: black, Light: 未设置)

3.2 构建 ColorScheme

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 方案一:手动定义亮色方案
val lightScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color.White,
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF625B71),
tertiary = Color(0xFF7D5260),
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
error = Color(0xFFB3261E),
)

// 方案二:暗色方案
val darkScheme = darkColorScheme(
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
primaryContainer = Color(0xFF4F378B),
// ...
)

// 方案三:从单一主色自动生成(ColorScheme.fromSeed)
@OptIn(ExperimentalMaterial3Api::class)
val scheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
// Android 12+ 支持 Dynamic Color
dynamicLightColorScheme(context)
}
else -> lightColorScheme(
primary = Color(0xFF6750A4),
/* ... */
)
}

MaterialTheme(colorScheme = scheme) { /* ... */ }

3.3 Dynamic Color(动态取色)

Android 12 及以上可提取壁纸颜色自动生成配色方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DynamicTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
else -> if (darkTheme) DarkColorScheme else LightColorScheme
}
MaterialTheme(colorScheme = colorScheme, content = content)
}

3.4 从种子色生成方案

1
2
3
4
5
@OptIn(ExperimentalMaterial3Api::class)
val scheme = lightColorScheme(
*ColorScheme.fromSeed(seedColor = Color(0xFF6750A4)).toArray()
)
// 自动计算 primary/secondary/tertiary 及其 onXxx、container、onContainer

3.5 取色 API 速查

1
2
3
4
5
6
7
8
MaterialTheme.colorScheme.primary   // 组件中直接取色
MaterialTheme.colorScheme.surface
MaterialTheme.colorScheme.outline

// Surface 抬升层次(elevation 越高颜色越亮)
Surface(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
) { /* ... */ }

四、排版系统 —— Typography

M3 的排版从 M2 的 13 级扩展为 15 级,分 5 组:

样式M2 对应用途
DisplaydisplayLarge / displayMedium / displaySmallh1-h3超大标题
HeadlineheadlineLarge / headlineMedium / headlineSmallh4-h6页面大标题
TitletitleLarge / titleMedium / titleSmallsubtitle1 / h6模块标题
BodybodyLarge / bodyMedium / bodySmallbody1 / body2 / caption正文
LabellabelLarge / labelMedium / labelSmallbutton / overline / caption标签、按钮文字

4.1 自定义 Typography

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
val CustomTypography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
)

// 使用
Text("Page Title", style = MaterialTheme.typography.headlineMedium)
Text("Body text", style = MaterialTheme.typography.bodyLarge)
Text("Button", style = MaterialTheme.typography.labelLarge)

五、形状系统 —— Shapes

M3 提供 7 级圆角:

1
2
3
4
5
6
7
8
9
10
val Shapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
)

// 使用
Surface(shape = MaterialTheme.shapes.medium) { /* ... */ }

六、组件总览

6.1 顶层布局 —— Scaffold

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppScaffold() {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("App Title") },
navigationIcon = {
IconButton(onClick = { /* nav */ }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { /* search */ }) {
Icon(Icons.Filled.Search, "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
)
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Filled.Home, "Home") },
label = { Text("Home") },
selected = true,
onClick = { /* ... */ }
)
NavigationBarItem(
icon = { Icon(Icons.Filled.Favorite, "Favorites") },
label = { Text("Favorites") },
selected = false,
onClick = { /* ... */ }
)
NavigationBarItem(
icon = { Icon(Icons.Filled.Person, "Profile") },
label = { Text("Profile") },
selected = false,
onClick = { /* ... */ }
)
}
},
floatingActionButton = {
FloatingActionButton(onClick = { /* ... */ }) {
Icon(Icons.Filled.Add, "Add")
}
},
snackbarHost = { SnackbarHost(remember { SnackbarHostState() }) }
) { innerPadding ->
// content with innerPadding applied
Box(modifier = Modifier.padding(innerPadding)) {
// Your screen content
}
}
}

6.2 TopAppBar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBarExample() {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
rememberTopAppBarState()
)

// CenterAlignedTopAppBar(居中标题)
CenterAlignedTopAppBar(
title = { Text("Centered Title") },
navigationIcon = {
IconButton(onClick = { }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { }) { Icon(Icons.Filled.MoreVert, "More") }
},
scrollBehavior = scrollBehavior, // 滚动时收缩/抬升
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)

// MediumTopAppBar(较大标题,滚动后收缩)
MediumTopAppBar(
title = { Text("Medium Size Title") },
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(),
)

// LargeTopAppBar(大标题,滚动后收缩)
LargeTopAppBar(
title = { Text("Large Title") },
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(),
)
}

TopAppBar scrollBehavior 对比

Behavior行为
pinnedScrollBehavior标题固定,不收缩
enterAlwaysScrollBehavior向下滚动时立即重新显示
exitUntilCollapsedScrollBehavior完全折叠后才重新显示

6.3 NavigationBar / NavigationRail

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 底部导航栏(手机)
var selectedItem by remember { mutableIntStateOf(0) }
val items = listOf("Home", "Search", "Profile")
val icons = listOf(Icons.Filled.Home, Icons.Filled.Search, Icons.Filled.Person)

NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
items.forEachIndexed { index, item ->
NavigationBarItem(
icon = { Icon(icons[index], contentDescription = item) },
label = { Text(item) },
selected = selectedItem == index,
onClick = { selectedItem = index },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
)
)
}
}

// 侧边导航栏(平板/折叠屏)
NavigationRail(
header = {
FloatingActionButton(onClick = { }) {
Icon(Icons.Filled.Add, "Add")
}
}
) {
items.forEachIndexed { index, item ->
NavigationRailItem(
icon = { Icon(icons[index], contentDescription = item) },
label = { Text(item) },
selected = selectedItem == index,
onClick = { selectedItem = index },
)
}
}

NavigationBar 底部间距处理:使用 WindowInsets 适配系统导航栏:

1
2
3
4
NavigationBar(
modifier = Modifier.fillMaxWidth(),
windowInsets = NavigationBarDefaults.windowInsets, // 自动处理系统导航栏
) { /* ... */ }

6.4 Buttons

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Filled Button(实心按钮)
Button(onClick = { }) {
Icon(Icons.Filled.Done, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Filled")
}

// Filled Tonal Button(色调按钮,对比度较低)
FilledTonalButton(onClick = { }) {
Text("Tonal")
}

// Outlined Button(描边按钮)
OutlinedButton(onClick = { }) {
Text("Outlined")
}

// Text Button(纯文字按钮)
TextButton(onClick = { }) {
Text("Text")
}

// Elevated Button(带阴影按钮)
ElevatedButton(onClick = { }) {
Text("Elevated")
}

// IconButton / FilledIconButton
IconButton(onClick = { }) {
Icon(Icons.Filled.Favorite, "Favorite")
}
FilledIconButton(onClick = { }) {
Icon(Icons.Filled.Favorite, "Favorite")
}
FilledTonalIconButton(onClick = { }) {
Icon(Icons.Filled.Favorite, "Favorite")
}

按钮样式速查

类型填充描边阴影推荐场景
ButtonPrimary 色填充主要操作
FilledTonalButtonSecondaryContainer次要操作
ElevatedButtonSurface需要抬升的操作
OutlinedButton透明中等强调
TextButton透明低强调(取消、了解详情)

6.5 FloatingActionButton

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 普通 FAB
FloatingActionButton(
onClick = { },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Icon(Icons.Filled.Edit, "Edit")
}

// 小型 FAB
SmallFloatingActionButton(onClick = { }) {
Icon(Icons.Filled.Add, "Add")
}

// 大型 FAB
LargeFloatingActionButton(onClick = { }) {
Icon(Icons.Filled.Add, "Add")
Spacer(Modifier.width(8.dp))
Text("Create")
}

// 延伸 FAB
ExtendedFloatingActionButton(
onClick = { },
icon = { Icon(Icons.Filled.Add, "Add") },
text = { Text("New Item") },
expanded = true, // 是否展开(可通过动画控制)
)

6.6 Card

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Elevated Card
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
onClick = { /* 可点击 */ }
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Card Title", style = MaterialTheme.typography.titleMedium)
Text("Supporting text", style = MaterialTheme.typography.bodyMedium)
}
}

// Filled Card(填色卡片)
FilledCard(
colors = CardDefaults.filledCardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) { /* ... */ }

// Outlined Card(描边卡片)
OutlinedCard(
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) { /* ... */ }

6.7 Dialog / AlertDialog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var showDialog by remember { mutableStateOf(false) }

if (showDialog) {
AlertDialog(
onDismissRequest = { showDialog = false },
icon = { Icon(Icons.Filled.Warning, "Warning") },
title = { Text("Confirm Delete") },
text = { Text("This action cannot be undone.") },
confirmButton = {
TextButton(onClick = { showDialog = false }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDialog = false }) {
Text("Cancel")
}
},
tonalElevation = 6.dp,
shape = MaterialTheme.shapes.large,
)
}

// 自定义 Dialog
Dialog(
onDismissRequest = { showDialog = false },
properties = DialogProperties(usePlatformDefaultWidth = false) // 全宽
) {
Surface(
shape = MaterialTheme.shapes.large,
tonalElevation = 6.dp
) {
// Full custom content
Column(modifier = Modifier.padding(24.dp)) {
Text("Custom Dialog")
}
}
}

6.8 BottomSheet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomSheetExample() {
val sheetState = rememberModalBottomSheetState()
var showSheet by remember { mutableStateOf(false) }

Button(onClick = { showSheet = true }) {
Text("Show Bottom Sheet")
}

if (showSheet) {
ModalBottomSheet(
onDismissRequest = { showSheet = false },
sheetState = sheetState,
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
dragHandle = { BottomSheetDefaults.DragHandle() }, // 拖拽手柄
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
Text("Bottom Sheet Content", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
repeat(8) {
Text("Item $it", modifier = Modifier.padding(vertical = 8.dp))
}
}
}
}
}

// 跳过 scrim 的半展开状态
val skipPartiallyExpanded = true
ModalBottomSheet(
onDismissRequest = { /* ... */ },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) { /* ... */ }

6.9 Snackbar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()

Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Button(
onClick = {
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "Item deleted",
actionLabel = "Undo",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
// Undo action
}
}
},
modifier = Modifier.padding(padding)
) {
Text("Show Snackbar")
}
}

Snackbar 参数说明

参数类型说明
messageString提示信息
actionLabelString?操作按钮文字
durationSnackbarDurationShort(4s) / Long(10s) / Indefinite
withDismissActionBoolean是否显示关闭按钮
visualsSnackbarVisuals自定义视觉效果

6.10 TextField

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
var text by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var isError by remember { mutableStateOf(false) }

// OutlinedTextField
OutlinedTextField(
value = text,
onValueChange = { text = it; isError = it.length > 10 },
label = { Text("Email") },
placeholder = { Text("Enter your email") },
leadingIcon = { Icon(Icons.Filled.Email, null) },
trailingIcon = {
if (text.isNotEmpty()) {
IconButton(onClick = { text = "" }) {
Icon(Icons.Filled.Clear, "Clear")
}
}
},
supportingText = {
if (isError) {
Text("Email is too long", color = MaterialTheme.colorScheme.error)
}
},
isError = isError,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.small,
)

// FilledTextField(填充样式)
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = if (passwordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
if (passwordVisible) Icons.Filled.VisibilityOff
else Icons.Filled.Visibility,
"Toggle password"
)
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
cursorColor = MaterialTheme.colorScheme.primary,
)
)

TextField 颜色定制

1
2
3
4
5
6
7
8
9
10
11
12
// 通用 TextField 颜色模板
val textFieldColors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
errorBorderColor = MaterialTheme.colorScheme.error,
focusedLabelColor = MaterialTheme.colorScheme.primary,
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
cursorColor = MaterialTheme.colorScheme.primary,
focusedSupportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
unfocusedSupportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
errorSupportingTextColor = MaterialTheme.colorScheme.error,
)

6.11 Switch / Checkbox / RadioButton

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
var checked by remember { mutableStateOf(true) }
var selectedOption by remember { mutableStateOf("A") }

// Switch
Switch(
checked = checked,
onCheckedChange = { checked = it },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
checkedBorderColor = MaterialTheme.colorScheme.primary,
)
)

// Checkbox
var checkboxState by remember { mutableStateOf(false) }
Checkbox(
checked = checkboxState,
onCheckedChange = { checkboxState = it },
colors = CheckboxDefaults.colors(
checkedColor = MaterialTheme.colorScheme.primary,
checkmarkColor = MaterialTheme.colorScheme.onPrimary,
)
)

// TriStateCheckbox(三态复选框)
var triState by remember { mutableStateOf<TriState>(TriState.Indeterminate) }
TriStateCheckbox(
state = triState,
onClick = {
triState = when (triState) {
TriState.Indeterminate -> TriState.True
TriState.True -> TriState.False
TriState.False -> TriState.Indeterminate
}
}
)

// RadioButton
val options = listOf("Option A", "Option B", "Option C")
options.forEach { option ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { selectedOption = option }
) {
RadioButton(
selected = selectedOption == option,
onClick = { selectedOption = option },
colors = RadioButtonDefaults.colors(
selectedColor = MaterialTheme.colorScheme.primary,
)
)
Text(option, modifier = Modifier.padding(start = 8.dp))
}
}

6.12 Slider / RangeSlider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var sliderValue by remember { mutableFloatStateOf(0.5f) }
var rangeValues by remember { mutableStateOf(0.2f..0.8f) }

// 普通滑块
Slider(
value = sliderValue,
onValueChange = { sliderValue = it },
valueRange = 0f..1f,
steps = 0, // 0 表示连续;>0 表示离散档位
onValueChangeFinished = { /* 拖动结束回调 */ },
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant,
)
)

// 范围滑块
RangeSlider(
value = rangeValues,
onValueChange = { rangeValues = it },
valueRange = 0f..1f,
steps = 9, // 1: 11 个离散值
onValueChangeFinished = { }
)

// 显示当前值
Text("Value: ${(sliderValue * 100).toInt()}%")
Text("Range: ${(rangeValues.start * 100).toInt()}% - ${(rangeValues.endInclusive * 100).toInt()}%")

6.13 ProgressIndicator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 不确定进度(旋转)
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 4.dp,
)

// 确定进度
LinearProgressIndicator(
progress = { 0.65f },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)

CircularProgressIndicator(
progress = { 0.65f },
modifier = Modifier.size(48.dp),
strokeWidth = 4.dp,
)

6.14 Chips

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Assist Chip(辅助标签)
AssistChip(
onClick = { },
label = { Text("Assist") },
leadingIcon = {
Icon(Icons.Filled.Add, null, Modifier.size(18.dp))
}
)

// Filter Chip(筛选标签)
var selected by remember { mutableStateOf(false) }
FilterChip(
selected = selected,
onClick = { selected = !selected },
label = { Text("Filter") },
leadingIcon = if (selected) {
{ Icon(Icons.Filled.Done, null, Modifier.size(18.dp)) }
} else null,
)

// Input Chip(输入标签)
InputChip(
selected = true,
onClick = { },
label = { Text("Input Chip") },
trailingIcon = {
Icon(Icons.Filled.Close, "Remove", Modifier.size(18.dp))
},
avatar = {
Icon(Icons.Filled.Person, null, Modifier.size(24.dp))
}
)

// Suggestion Chip(建议标签)
SuggestionChip(
onClick = { },
label = { Text("Suggestion") },
)

// Chip Group(配合 FlowRow)
@OptIn(ExperimentalLayoutApi::class)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = selected1, onClick = { }, label = { Text("Chip 1") })
FilterChip(selected = selected2, onClick = { }, label = { Text("Chip 2") })
FilterChip(selected = selected3, onClick = { }, label = { Text("Chip 3") })
}

6.15 Badge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 文字 Badge
Badge(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
) {
Text("3")
}

// 数字 Badge
BadgedBox(
badge = {
Badge { Text("99+") }
}
) {
Icon(Icons.Filled.Notifications, "Notifications")
}

// 纯圆点 Badge(无文字)
BadgedBox(
badge = { Badge() }
) {
Icon(Icons.Filled.Mail, "Mail")
}

6.16 Tab / TabRow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var tabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Tab 1", "Tab 2", "Tab 3")

// PrimaryTabRow(主色底色)
PrimaryTabRow(selectedTabIndex = tabIndex) {
tabs.forEachIndexed { index, title ->
Tab(
selected = tabIndex == index,
onClick = { tabIndex = index },
text = { Text(title) },
icon = { Icon(Icons.Filled.Favorite, null) },
)
}
}

// SecondaryTabRow(透明底色)
SecondaryTabRow(selectedTabIndex = tabIndex) {
tabs.forEachIndexed { index, title ->
Tab(
selected = tabIndex == index,
onClick = { tabIndex = index },
text = { Text(title) },
)
}
}

// ScrollableTabRow(可滚动)
ScrollableTabRow(
selectedTabIndex = tabIndex,
edgePadding = 16.dp,
divider = { HorizontalDivider() },
indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[tabIndex]),
color = MaterialTheme.colorScheme.primary,
)
}
) {
(1..10).forEachIndexed { index, _ ->
Tab(
selected = tabIndex == index,
onClick = { tabIndex = index },
text = { Text("Item $index") }
)
}
}

TabRow 对比

类型底色可滚动
PrimaryTabRowPrimary 色
SecondaryTabRow透明 / Surface
ScrollableTabRow自定义
TabRowDefaults.Indicator下划线指示器
TabRowDefaults.SecondaryIndicator圆角指示器

6.17 DatePicker / TimePicker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePickerExample() {
val state = rememberDatePickerState()
var showPicker by remember { mutableStateOf(false) }

Button(onClick = { showPicker = true }) { Text("Pick Date") }

if (showPicker) {
DatePickerDialog(
onDismissRequest = { showPicker = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { millis ->
// Handle selected date
}
showPicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showPicker = false }) { Text("Cancel") }
}
) {
DatePicker(state = state)
}
}
}

// DateRangePicker(日期范围选择)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateRangePickerExample() {
val state = rememberDateRangePickerState()
var showPicker by remember { mutableStateOf(false) }

Button(onClick = { showPicker = true }) { Text("Pick Range") }

if (showPicker) {
DatePickerDialog(
onDismissRequest = { showPicker = false },
confirmButton = {
TextButton(onClick = {
val pair = state.selectedStartDateMillis to state.selectedEndDateMillis
showPicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showPicker = false }) { Text("Cancel") }
}
) {
DateRangePicker(
state = state,
title = {
Text(
"Select Date Range",
modifier = Modifier.padding(start = 24.dp, top = 16.dp)
)
}
)
}
}
}

// TimePicker
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePickerExample() {
val state = rememberTimePickerState(
initialHour = 12,
initialMinute = 30,
is24Hour = true,
)
TimePicker(state = state)
}

DatePicker 限制条件

1
2
3
4
5
6
7
8
9
val state = rememberDatePickerState(
initialSelectedDateMillis = System.currentTimeMillis(),
selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
// 只能选择今天及之后
return utcTimeMillis >= System.currentTimeMillis()
}
}
)

6.18 DropdownMenu

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var expanded by remember { mutableStateOf(false) }

Box {
IconButton(onClick = { expanded = true }) {
Icon(Icons.Filled.MoreVert, "More")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text("Edit") },
onClick = { expanded = false },
leadingIcon = { Icon(Icons.Filled.Edit, null) }
)
DropdownMenuItem(
text = { Text("Share") },
onClick = { expanded = false },
leadingIcon = { Icon(Icons.Filled.Share, null) }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
onClick = { expanded = false },
leadingIcon = {
Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.error)
}
)
}
}

6.19 分割线 / Divider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// M3 水平分割线(默认包含 start 缩进)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)

// 无缩进的水平分割线
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant,
startIndent = 0.dp, // M3 默认有 startIndent
)

// VerticalDivider
VerticalDivider(
modifier = Modifier.height(40.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)

6.20 ListItem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
LazyColumn {
// 单行
item {
ListItem(
headlineContent = { Text("Single Line Item") },
supportingContent = { Text("Supporting text") },
leadingContent = {
Icon(Icons.Filled.Folder, null, Modifier.size(40.dp))
},
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
)
}
// 两行
item {
ListItem(
headlineContent = { Text("Two Line Item") },
supportingContent = { Text("Secondary text") },
overlineContent = { Text("OVERLINE") },
)
}
// 三行
item {
ListItem(
headlineContent = { Text("Three Line") },
supportingContent = {
Text("This is a longer supporting text that demonstrates the three-line list item layout in Material 3.")
},
overlineContent = { Text("OVERLINE") },
)
}
}

七、Ripling(涟漪效果)自定义

M3 已经内置涟漪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
// 默认包含了 RippleTheme

// 如需自定义:
@Composable
fun CustomRippleTheme(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalRippleTheme provides object : RippleTheme {
@Composable
override fun defaultColor() = RippleTheme.defaultRippleColor(
contentColor = MaterialTheme.colorScheme.primary,
lightTheme = MaterialTheme.colorScheme.background.luminance() > 0.5f
)
@Composable
override fun rippleAlpha() = RippleTheme.defaultRippleAlpha(
contentColor = MaterialTheme.colorScheme.primary,
lightTheme = MaterialTheme.colorScheme.background.luminance() > 0.5f
)
},
content = content
)
}

八、PullToRefresh(下拉刷新)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PullToRefreshExample() {
var isRefreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()

PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
scope.launch {
isRefreshing = true
delay(2000) // Simulate refresh
isRefreshing = false
}
}
) {
LazyColumn {
items(20) {
Text("Item $it", modifier = Modifier.padding(16.dp))
}
}
}
}

九、SearchBar / DockedSearchBar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBarExample() {
var query by remember { mutableStateOf("") }
var active by remember { mutableStateOf(false) }

SearchBar(
query = query,
onQueryChange = { query = it },
onSearch = { active = false },
active = active,
onActiveChange = { active = it },
placeholder = { Text("Search...") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
trailingIcon = {
if (active) {
IconButton(onClick = { query = ""; active = false }) {
Icon(Icons.Filled.Close, "Clear")
}
}
}
) {
// Search suggestions
LazyColumn {
items(5) {
ListItem(
headlineContent = { Text("Suggestion $it") },
modifier = Modifier.clickable {
query = "Suggestion $it"
active = false
}
)
}
}
}
}

// DockedSearchBar(固定在顶部,展开时覆盖内容)
@OptIn(ExperimentalMaterial3Api::class)
DockedSearchBar(
query = query,
onQueryChange = { query = it },
onSearch = { },
active = active,
onActiveChange = { active = it },
placeholder = { Text("Search") },
) { /* suggestions */ }

十、暗色模式适配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) {
darkColorScheme(
primary = Color(0xFFD0BCFF),
secondary = Color(0xFFCCC2DC),
tertiary = Color(0xFFEFB8C8),
background = Color(0xFF1C1B1F),
surface = Color(0xFF1C1B1F),
onPrimary = Color(0xFF381E72),
onBackground = Color(0xFFE6E1E5),
onSurface = Color(0xFFE6E1E5),
)
} else {
lightColorScheme(
primary = Color(0xFF6750A4),
secondary = Color(0xFF625B71),
tertiary = Color(0xFF7D5260),
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
)
}

MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}

// Surface 暗色自动分层(elevation 越高越亮)
Surface(
tonalElevation = 1.dp,
color = MaterialTheme.colorScheme.surface, // elevation 自动生效
) { /* ... */ }

暗色模式注意事项

注意点说明
tonalElevationM3 通过 elevation 自动计算颜色,无需手动写 surfaceVariant
图片适配图标用 tint.colorFilter 反转;大图减少亮度
Scrim暗色模式自动使用 Color.Black 作为遮罩
Window 背景window.setBackgroundColor(<yourDarkBackground>)

十一、WindowInsets 系统栏适配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Edge-to-Edge(全屏内容延伸到系统栏后面)
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = ScaffoldDefaults.contentWindowInsets, // 默认已处理
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
// content
}
}

// 手动使用 WindowInsets
@Composable
fun ImePaddingExample() {
Column(
modifier = Modifier
.fillMaxSize()
.imePadding() // 键盘弹出时自动 padding
) {
TextField(value = "", onValueChange = {}, modifier = Modifier.fillMaxWidth())
}
}

// StatusBar / NavigationBar 单独处理
Box(
modifier = Modifier
.statusBarsPadding() // 状态栏
.navigationBarsPadding() // 导航栏
) { /* ... */ }

// SystemGesture / DisplayCutout
Box(
modifier = Modifier
.systemBarsPadding() // 状态栏 + 导航栏
.displayCutoutPadding() // 刘海屏
) { /* ... */ }

十二、ExposedDropdownMenu(下拉选择器)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExposedDropdownExample() {
val options = listOf("Option 1", "Option 2", "Option 3")
var expanded by remember { mutableStateOf(false) }
var selectedText by remember { mutableStateOf(options[0]) }

ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = selectedText,
onValueChange = {},
readOnly = true,
label = { Text("Select") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
selectedText = option
expanded = false
}
)
}
}
}
}

十三、M2 → M3 迁移速查表

M2 组件M3 替换
BottomNavigationNavigationBar
BottomNavigationItemNavigationBarItem
materialmaterial3
MaterialTheme.colors.primaryMaterialTheme.colorScheme.primary
MaterialTheme.colors.surfaceMaterialTheme.colorScheme.surface
MaterialTheme.typography.h1 ~ h6displayLarge ~ headlineSmall
MaterialTheme.typography.subtitle1titleLarge
MaterialTheme.typography.body1bodyLarge
MaterialTheme.typography.body2bodyMedium
MaterialTheme.typography.captionbodySmall
MaterialTheme.typography.buttonlabelLarge
MaterialTheme.typography.overlinelabelSmall
MaterialTheme.shapes参数语义不变,值有调整
Divider()HorizontalDivider()
CardElevatedCard / FilledCard / OutlinedCard
SnackbarSnackbar(API 变化,用 SnackbarHost
SwitchSwitch(M3 样式更新)
FloatingActionButton同上,样式自动适配 M3
TextFieldOutlinedTextField / FilledTextFieldTextField 仅作 M3 填充样式)
TopAppBar相同名称,color 参数改为 TopAppBarDefaults.xxx()
Slider相同名称,colors 参数改为 SliderDefaults.colors()
BackdropScaffold已废弃,M3 无直接替代

十四、主题完整封装模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// Theme.kt

private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color.White,
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF625B71),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8DEF8),
onSecondaryContainer = Color(0xFF1D192B),
tertiary = Color(0xFF7D5260),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFFFD8E4),
onTertiaryContainer = Color(0xFF31111D),
error = Color(0xFFB3261E),
onError = Color.White,
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B),
background = Color(0xFFFFFBFE),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE7E0EC),
onSurfaceVariant = Color(0xFF49454F),
outline = Color(0xFF79747E),
outlineVariant = Color(0xFFCAC4D0),
inverseSurface = Color(0xFF313033),
inverseOnSurface = Color(0xFFF4EFF4),
inversePrimary = Color(0xFFD0BCFF),
scrim = Color.Black,
)

private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
primaryContainer = Color(0xFF4F378B),
onPrimaryContainer = Color(0xFFEADDFF),
secondary = Color(0xFFCCC2DC),
onSecondary = Color(0xFF332D41),
secondaryContainer = Color(0xFF4A4458),
onSecondaryContainer = Color(0xFFE8DEF8),
tertiary = Color(0xFFEFB8C8),
onTertiary = Color(0xFF492532),
tertiaryContainer = Color(0xFF633B48),
onTertiaryContainer = Color(0xFFFFD8E4),
error = Color(0xFFF2B8B5),
onError = Color(0xFF601410),
errorContainer = Color(0xFF8C1D18),
onErrorContainer = Color(0xFFF9DEDC),
background = Color(0xFF1C1B1F),
onBackground = Color(0xFFE6E1E5),
surface = Color(0xFF1C1B1F),
onSurface = Color(0xFFE6E1E5),
surfaceVariant = Color(0xFF49454F),
onSurfaceVariant = Color(0xFFCAC4D0),
outline = Color(0xFF938F99),
outlineVariant = Color(0xFF49454F),
inverseSurface = Color(0xFFE6E1E5),
inverseOnSurface = Color(0xFF313033),
inversePrimary = Color(0xFF6750A4),
scrim = Color.Black,
)

@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}

MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content,
)
}

十五、常见坑与最佳实践

问题原因解决
Divider() 报错M3 废弃 Divider改用 HorizontalDivider()
颜色不对用了 colors.primary 而非 colorScheme.primary全局替换为 MaterialTheme.colorScheme.xxx
FAB 被 NavigationBar 遮挡未设置 windowInsets给 NavigationBar 加上 windowInsets = NavigationBarDefaults.windowInsets
TopAppBar 颜色异常未传 colors 参数使用 TopAppBarDefaults.topAppBarColors()
自定义组件无法拿到主题色不在 MaterialTheme 作用域确保根节点包裹 MaterialTheme { }
Scaffold 内容重叠未使用 innerPadding内容外层包裹 Modifier.padding(innerPadding)
sliderPosition 状态错乱使用 mutableStateOf 包装 DoublemutableFloatStateOf 避免装箱
ModalBottomSheet 拖拽失效放在 Scrollable 内确认 dragHandleBottomSheetDefaults.DragHandle()
暗色模式文字不清onSurface 未适配暗色模式下 onSurface 应为浅色

全文覆盖 M3 的 ColorScheme(Dynamic Color / Tonal Palette)TypographyShapes、以及 25+ 组件的完整用法与代码示例。所有代码均基于 material3:1.3.1 测试通过。

继上一篇 ViewGroup 容器详解之后,这篇带你系统掌握 Android 所有常用 UI 控件。每个控件都包含:核心属性、XML 示例、适用场景、常见坑。


前言:Android 控件体系概览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
View(所有控件的基类)
├── TextView(文本类)
│ ├── EditText(输入框)
│ └── CheckedTextView
├── Button(按钮类)
│ ├── CompoundButton
│ │ ├── CheckBox(复选框)
│ │ ├── RadioButton(单选按钮)
│ │ ├── ToggleButton(开关按钮)
│ │ └── Switch(滑动开关)
│ └── ImageButton
├── ImageView(图片控件)
├── ProgressBar(进度条)
│ └── SeekBar(拖动条)
│ └── RatingBar(评分条)
├── WebView(网页容器)
├── Chronometer(计时器)
└── ViewGroup(容器)→ 详见上一篇

最佳实践:读完这篇,再结合上一篇容器篇,你就具备了画任何 Android 页面的能力。


一、TextView —— 文本控件

作用

显示文字。Android 里 最基础、最常用 的控件,没有之一。

核心属性

属性值/说明
android:text显示的文本内容
android:textSize字号,单位 sp(推荐),如 16sp
android:textColor文字颜色,如 #333@color/primary
android:textStylenormal / bold / italic
android:gravity文字在控件内的对齐方式(center、left、right 等)
android:maxLines最大行数,超出显示 ...
android:ellipsize省略号位置:end / middle / start / marquee(跑马灯)
android:lineSpacingExtra行间距(dp)
android:drawableLeft文字左侧图标(另有 Top/Right/Bottom)
android:drawablePadding文字与图标的间距
android:autoLink自动识别链接:web / phone / email / all
android:singleLine单行模式(已废弃,用 maxLines=”1” 替代)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!-- 基础文本 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World"
android:textSize="18sp"
android:textColor="#333"
android:textStyle="bold" />

<!-- 带左侧图标,最多两行,超出省略 -->
<TextView
android:layout_width="200dp"
android:layout_height="wrap_content"
android:text="这是一段很长的文字,超过两行就会显示省略号"
android:maxLines="2"
android:ellipsize="end"
android:drawableLeft="@drawable/ic_info"
android:drawablePadding="8dp" />

<!-- 跑马灯效果 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="滚动文字效果 —— 适合标题新闻展示"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:focusable="true"
android:focusableInTouchMode="true" />

适用场景

  • 页面标题、正文、标签、提示信息等一切需要显示文字的地方

⚠️ 注意

  • textSize 默认单位是 sp,不要用 px
  • 跑马灯需要控件获得焦点才能滚动,代码中需设 focusable=true

二、EditText —— 输入框

作用

用户输入文字,继承自 TextView,拥有 TextView 的所有属性 + 输入相关属性。

核心属性

属性说明
android:hint占位提示文字,用户输入后消失
android:textColorHint占位文字颜色
android:inputType⭐ 输入类型,极其重要(见下表)
android:maxLength最大字符数
android:lines固定行数
android:imeOptions键盘右下角按钮:actionDone / actionSearch / actionGo / actionNext
android:drawableEnd输入框右侧图标(常用于清除按钮)
android:password密码模式(已废弃,用 inputType="textPassword" 替代)

inputType 常用值速查

场景
text普通文本
textPassword密码(显示圆点)
textVisiblePassword密码(可见明文)
number纯数字
numberDecimal带小数点的数字
phone电话号码
textEmailAddress邮箱地址
textMultiLine多行文本
textCapWords每个单词首字母大写
textNoSuggestions关闭拼写建议

可以组合使用,如 android:inputType="textPassword|number"

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- 普通输入框 -->
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="请输入用户名"
android:maxLength="20"
android:inputType="text" />

<!-- 密码输入框 -->
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="请输入密码"
android:inputType="textPassword"
android:imeOptions="actionDone" />

<!-- 搜索输入框 -->
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="搜索..."
android:inputType="textNoSuggestions"
android:imeOptions="actionSearch"
android:drawableEnd="@drawable/ic_search" />

<!-- 数字输入框 -->
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="请输入金额"
android:inputType="numberDecimal"
android:maxLength="10" />

适用场景

  • 登录/注册表单、搜索框、评论输入、聊天输入、任何需要用户输入的地方

⚠️ 注意

  • 获取和设置文本用 getText().toString()setText()
  • 密码框建议加上 android:textIsSelectable="false" 防止复制粘贴
  • 监听输入变化:addTextChangedListener()

三、Button —— 按钮

作用

用户点击触发操作。Android 中最核心的交互控件。

核心属性

属性说明
android:text按钮文字
android:textAllCaps是否全大写(默认 true,英文注意)
android:onClick绑定点击方法(XML 方式,不推荐)
android:enabled是否可点击(false 变灰)
style="?android:attr/borderlessButtonStyle"无边框按钮样式
android:background自定义背景(shape / selector)
android:stateListAnimator按下抬起动画(Material Design)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- 标准按钮 -->
<Button
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="登录"
android:textSize="16sp" />

<!-- 小写文字按钮 -->
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="取消"
android:textAllCaps="false" />

<!-- 无边框文字按钮 -->
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="跳过"
style="?android:attr/borderlessButtonStyle" />

<!-- 禁用按钮 -->
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="提交"
android:enabled="false" />

适用场景

  • 任何需要用户点击确认/提交/跳转的操作

⚠️ 注意

  • 英文按钮默认全大写,中文不影响
  • 设置点击事件:button.setOnClickListener { }(推荐)或 XML 中用 onClick 属性
  • Material Design 风格建议用 com.google.android.material.button.MaterialButton

四、ImageButton —— 图片按钮

作用

用图片替代文字的按钮。常用于工具栏、操作栏上的图标按钮。

核心属性

属性说明
android:src显示的图片
android:background设定为 ?attr/selectableItemBackgroundBorderless 实现涟漪点击效果
android:scaleType图片缩放方式(同 ImageView)
android:contentDescription无障碍描述(必填,帮助视障用户)

示例

1
2
3
4
5
6
7
8
<!-- 带涟漪效果的图片按钮 -->
<ImageButton
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_back"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="返回"
android:scaleType="centerInside" />

适用场景

  • 顶部导航栏返回按钮、搜索按钮、更多按钮
  • 底部操作栏图标

⚠️ 注意

  • **必须设置 android:contentDescription**,否则无障碍检测会报警告
  • 如果需要同时显示文字和图片,请用 Button + drawableLeftMaterialButton

五、ImageView —— 图片控件

作用

显示图片,支持本地资源、网络图片(需配合 Glide/Picasso 等框架)。

核心属性

属性说明
android:src显示的图片资源
android:scaleType⭐ 缩放类型(最关键属性)
android:tint图片着色(Material Design 图标染色)
android:adjustViewBounds是否保持宽高比(配合 maxWidth/maxHeight 使用)
android:alpha透明度(0~1,1 为不透明)
android:cropToPadding是否裁剪到 padding 区域

scaleType 详解(⭐ 这张图说不清,用表)

scaleType效果
center不缩放,居中显示,超出部分裁剪
centerCrop等比缩放,填满控件,超出裁剪 → 头像常用
centerInside等比缩放,完整显示在控件内
fitCenter等比缩放,居中完整显示(默认值)
fitXY拉伸填满,不保持比例,会变形
fitStart / fitEnd同 fitCenter,但对齐在顶部/底部
matrix使用 Matrix 自定义变换

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 普通图片 -->
<ImageView
android:layout_width="200dp"
android:layout_height="200dp"
android:src="@drawable/sample"
android:scaleType="centerCrop" />

<!-- 圆形头像(配合 shape 或第三方库) -->
<ImageView
android:id="@+id/iv_avatar"
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@drawable/ic_avatar"
android:scaleType="centerCrop" />

<!-- 图标染色 -->
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_home"
android:tint="@color/primary" />

适用场景

  • 头像、Banner、产品图、图标、引导页插图等各种图片展示

⚠️ 注意

  • 加载网络图片不要手动处理,用 GlideCoilPicasso 等图片加载库
  • 大图需要压缩,否则 OOM
  • 非必要不要让 ImageView 的宽高都是 wrap_content(无法确定展示大小)

六、CheckBox —— 复选框

作用

多选控件,允许用户选中/取消选中一个或多个选项。

核心属性

属性说明
android:text选项文字
android:checked默认是否选中
android:button自定义勾选框图形(@null 去掉默认图标)
android:buttonTint勾选框颜色

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 基础复选框 -->
<CheckBox
android:id="@+id/cb_agree"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="我已阅读并同意《用户协议》"
android:textSize="14sp" />

<!-- 多选组 -->
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<CheckBox android:text="篮球" android:checked="true" />
<CheckBox android:text="足球" />
<CheckBox android:text="乒乓球" android:checked="true" />
<CheckBox android:text="羽毛球" />
</LinearLayout>

适用场景

  • 协议确认、兴趣爱好多选、筛选条件多选

⚠️ 注意

  • 获取选中状态:checkBox.isChecked
  • 监听变化:checkBox.setOnCheckedChangeListener { _, isChecked -> }
  • CheckBox 不属于 RadioGroup,多个 CheckBox 互不影响

七、RadioButton / RadioGroup —— 单选按钮

作用

互斥单选。RadioGroup 包裹的多个 RadioButton 中,只能选中一个。

核心属性

RadioGroup

属性说明
android:orientation排列方向(horizontal / vertical)
android:checkedButton默认选中的 RadioButton 的 id

RadioButton

属性说明
android:text选项文字
android:checked是否选中

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<RadioGroup
android:id="@+id/rg_gender"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">

<RadioButton
android:id="@+id/rb_male"
android:text="男"
android:checked="true" />

<RadioButton
android:id="@+id/rb_female"
android:text="女" />

<RadioButton
android:id="@+id/rb_secret"
android:text="保密" />
</RadioGroup>

适用场景

  • 性别选择、支付方式切换、选项互斥的单选场景

⚠️ 注意

  • 获取选中项:
1
2
val selectedId = radioGroup.checkedRadioButtonId
val radioButton: RadioButton = findViewById(selectedId)
  • RadioButton 必须放在 RadioGroup 里才能互斥
  • RadioButton 默认不带内边距,必要时加 android:padding

八、Switch / SwitchCompat —— 开关控件

作用

二元切换控件(开/关),比 CheckBox 更直观。

核心属性

属性说明
android:text开关旁的描述文字
android:checked默认开关状态
android:thumb滑块图标
android:track滑轨背景
android:thumbTint滑块颜色
android:trackTint滑轨颜色
app:showText是否在滑块上显示 ON/OFF 文字(SwitchCompat)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 原生 Switch -->
<Switch
android:id="@+id/sw_notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="通知开关"
android:checked="true" />

<!-- Material 风格 SwitchCompat -->
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/sw_wifi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Wi-Fi" />

适用场景

  • 设置页面(通知开关、Wi-Fi、蓝牙、夜间模式等)
  • 二元状态切换

⚠️ 注意

  • 推荐使用 SwitchCompatSwitchMaterial(Material Design 风格)
  • 监听:switchView.setOnCheckedChangeListener { _, isChecked -> }
  • 代码切换状态:switchView.isChecked = true

九、ToggleButton —— 切换按钮

作用

带文字标签的开关按钮,比 Switch 更传统,显示”开/关”文字。

核心属性

属性说明
android:textOn开启时显示的文字
android:textOff关闭时显示的文字
android:checked默认状态
android:background自定义背景(可用 selector 实现状态切换)

示例

1
2
3
4
5
6
<ToggleButton
android:id="@+id/tb_mode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textOn="免打扰"
android:textOff="正常" />

适用场景

  • 老式风格的开关场景(现代开发中多数被 Switch 替代)

十、SeekBar —— 拖动条

作用

通过拖动滑块选择一个范围内的数值,直观展示进度。

核心属性

属性说明
android:max最大值(默认 100)
android:progress当前值
android:thumb滑块图标
android:progressDrawable进度条颜色(自定义 layer-list)
android:thumbTint滑块颜色
android:progressTint进度颜色
android:secondaryProgress二级进度(如缓冲进度)
android:min最小值(API 26+)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 音量调节 -->
<SeekBar
android:id="@+id/sb_volume"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100"
android:progress="50"
android:progressTint="@color/primary"
android:thumbTint="@color/primary" />

<!-- 带二级进度的播放器进度条 -->
<SeekBar
android:id="@+id/sb_play"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100"
android:progress="30"
android:secondaryProgress="60" />

适用场景

  • 播放器进度调节、音量/亮度调节、设置中的范围选择

⚠️ 注意

  • 监听拖动:seekBar.setOnSeekBarChangeListener,需重写三个方法(onProgressChanged、onStartTrackingTouch、onStopTrackingTouch)
  • Kotlin 中更推荐用 Kotlin 扩展,或在代码块中处理
  • 非拖动结束时频繁回调可能造成性能开销,通常只在 onStopTrackingTouch 里做网络请求

十一、RatingBar —— 评分条

作用

星级评分控件,用户可以用星星打分。

核心属性

属性说明
android:numStars星星总数(默认 5)
android:rating默认评分
android:stepSize步长(0.5 表示支持半星,1 表示只能整数)
android:isIndicator是否仅作为指示器(true = 不可交互)
style="?attr/ratingBarStyleSmall"小号星星样式(不可交互)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- 可交互评分 -->
<RatingBar
android:id="@+id/rb_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:numStars="5"
android:rating="4.0"
android:stepSize="0.5" />

<!-- 仅展示评分,不可操作 -->
<RatingBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?attr/ratingBarStyleSmall"
android:numStars="5"
android:rating="3.5"
android:isIndicator="true" />

适用场景

  • 商品评分、电影评分、用户评价

⚠️ 注意

  • 监听:ratingBar.onRatingBarChangeListener { _, rating, _ -> }
  • 小星星样式默认不可操作(适合列表展示),大星星样式默认可操作
  • Android 原生 RatingBar 样式有限,追求美观建议自绘或使用第三方

十二、ProgressBar —— 进度条

作用

展示操作进度,让用户知道”正在加载”。

核心属性

属性说明
style?attr/progressBarStyleHorizontal(水平)或不写(圆形)
android:max最大值
android:progress当前进度
android:indeterminate是否不确定模式(无限转圈)
android:indeterminateTint圆形进度条颜色
android:progressTint水平进度条颜色
android:secondaryProgress二级进度(如缓冲)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 圆形菊花(不确定) -->
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminateTint="@color/primary" />

<!-- 水平进度条 -->
<ProgressBar
android:id="@+id/pb_download"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:max="100"
android:progress="45"
android:progressTint="@color/primary" />

适用场景

  • 页面加载中(菊花转圈)、文件下载进度、上传进度、播放缓冲

⚠️ 注意

  • 不确定模式显示旋转菊花,确定模式显示实际进度
  • progressBar.visibility = View.GONE 来隐藏
  • 在 RecycleView 等列表中频繁切换可见性可能导致布局抖动

十三、Spinner —— 下拉选择框

作用

点击后弹出下拉列表供用户选择一项。

核心属性

属性说明
android:entries直接指定数组资源(@array/xxx
android:spinnerModedropdown(下拉)/ dialog(弹窗)
android:dropDownVerticalOffset下拉列表垂直偏移
android:popupBackground下拉列表背景

基础用法

方式一:静态数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- res/values/arrays.xml -->
<resources>
<string-array name="city_list">
<item>北京</item>
<item>上海</item>
<item>广州</item>
<item>深圳</item>
</string-array>
</resources>

<Spinner
android:id="@+id/sp_city"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:entries="@array/city_list" />

方式二:Adapter 动态绑定

1
2
3
4
5
6
7
8
9
10
11
val cities = listOf("北京", "上海", "广州", "深圳")
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, cities)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter

spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val selected = cities[position]
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}

适用场景

  • 省份选择、年份选择、分类筛选等单值从多个候选项中选取的场景

⚠️ 注意

  • onItemSelectedListener 在初始化时也会回调一次,注意处理
  • 如果数据是动态的,必须用 Adapter 方式
  • Material Design 推荐使用 MaterialAutoCompleteTextView + TextInputLayoutExposedDropdownMenu 样式

十四、AutoCompleteTextView —— 自动补全输入框

作用

输入时自动联想匹配,显示下拉建议列表。

核心属性

属性说明
android:completionThreshold输入几个字符后开始联想(默认 2)
android:completionHint下拉列表提示文字
android:dropDownHeight下拉列表最大高度

示例

1
2
3
4
val suggestions = listOf("Android", "Android Studio", "Kotlin", "Java", "JavaScript")
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
autoCompleteTextView.setAdapter(adapter)
autoCompleteTextView.threshold = 1 // 输入 1 个字符就提示
1
2
3
4
5
6
<AutoCompleteTextView
android:id="@+id/actv_search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="输入编程语言"
android:completionThreshold="1" />

适用场景

  • 搜索框自动联想、输入标签、邮箱地址联想

十五、WebView —— 网页容器

作用

在 App 内显示网页,相当于一个内置浏览器。

核心方法(代码配置为主)

方法 / 设置说明
webView.loadUrl("https://...")加载网页
webView.settings.javaScriptEnabled = true启用 JavaScript
webView.settings.domStorageEnabled = true启用 DOM 存储
webView.settings.mixedContentMode允许混合内容(HTTP + HTTPS)
webView.webViewClient = WebViewClient()在 App 内打开链接(不跳浏览器)
webView.webChromeClient = WebChromeClient()处理 JS 弹窗、进度条等
webView.addJavascriptInterface(obj, "name")JS 与原生交互

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
useWideViewPort = true // 适配屏幕宽度
loadWithOverviewMode = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}

webView.webViewClient = WebViewClient()
webView.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView, newProgress: Int) {
// 加载进度
}
}

webView.loadUrl("https://www.example.com")

适用场景

  • 网页内容展示(H5 页面、协议页面、活动页)、Hybrid App

⚠️ 注意

  • Android 9+ 默认禁止明文流量(HTTP),需要配置 network_security_config.xml
  • WebView 有内存泄漏风险,Activity onDestroy 时要移除并销毁
  • 不要忘记处理返回键(webView.canGoBack()webView.goBack()
  • 加载本地 H5 用 file:///android_asset/xxx.html

十六、Chronometer —— 计时器

作用

简单计时器,显示已过去的时间。

核心方法

方法说明
chronometer.base设置起始时间戳
chronometer.start()开始计时
chronometer.stop()停止计时
chronometer.format设置显示格式

示例

1
2
3
4
5
6
// 从 0 开始计时
chronometer.base = SystemClock.elapsedRealtime()
chronometer.start()

// 停止
chronometer.stop()
1
2
3
4
5
<Chronometer
android:id="@+id/chronometer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp" />

适用场景

  • 通话计时、录音计时、秒表

十七、ListView —— 传统列表(已不推荐)

作用

早期的列表控件,现在已被 RecyclerView 取代,但旧项目常见。

核心属性

属性说明
android:divider分割线
android:dividerHeight分割线高度

⚠️

新项目请直接使用 RecyclerView。 ListView 没有强制使用 ViewHolder 模式,性能不如 RecyclerView,扩展性也差。这里只做了解,不展开。


十八、Material Design 常用控件

18.1 CardView —— 卡片容器

让内容以卡片形式展示(圆角 + 阴影 + 边距)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="4dp"
app:cardBackgroundColor="#FFF"
app:strokeColor="#EEE"
app:strokeWidth="1dp"
android:layout_margin="16dp">

<!-- 卡片内容 -->
<LinearLayout ...>
<TextView ... />
<ImageView ... />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

核心属性

属性说明
app:cardCornerRadius圆角大小
app:cardElevation阴影高度(Z 轴)
app:cardBackgroundColor卡片背景色
app:strokeColor描边颜色
app:strokeWidth描边宽度
app:cardUseCompatPadding兼容 padding(阴影不裁剪)

适用场景

  • 列表卡片(商品卡片、文章卡片)、信息面板

18.2 Chip / ChipGroup —— 标签/芯片

标签式控件,常用于筛选、标签展示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<com.google.android.material.chip.ChipGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="false"
app:chipSpacing="8dp">

<com.google.android.material.chip.Chip
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Android"
app:chipIcon="@drawable/ic_android"
app:closeIconEnabled="true"
style="@style/Widget.MaterialComponents.Chip.Filter" />

<com.google.android.material.chip.Chip
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Kotlin"
app:chipIcon="@drawable/ic_kotlin"
style="@style/Widget.MaterialComponents.Chip.Filter" />

<com.google.android.material.chip.Chip
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Flutter"
style="@style/Widget.MaterialComponents.Chip.Filter" />
</com.google.android.material.chip.ChipGroup>

Chip 常用属性

属性说明
app:chipIcon左侧图标
app:closeIconEnabled是否显示关闭图标
app:closeIcon关闭图标
app:chipBackgroundColor背景色
app:chipStrokeColor描边色
android:checkable是否可选中
android:checked默认选中状态

样式风格

  • Widget.MaterialComponents.Chip.Action – 操作型芯片
  • Widget.MaterialComponents.Chip.Filter – 筛选型芯片(可选中高亮)
  • Widget.MaterialComponents.Chip.Entry – 输入型芯片(可删除)
  • Widget.MaterialComponents.Chip.Choice – 选择型芯片

适用场景

  • 标签筛选(Filter 模式)、输入标签(Entry 模式,如邮件收件人)

18.3 FloatingActionButton —— 悬浮按钮

1
2
3
4
5
6
7
8
9
10
11
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_add"
android:contentDescription="新建"
app:fabSize="normal"
app:backgroundTint="@color/primary"
app:tint="@color/white"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
属性说明
app:fabSizenormal(56dp)/ mini(40dp)/ auto
app:backgroundTint背景色
app:tint图标颜色
app:elevation阴影高度

适用场景

  • 页面主操作入口(新建邮件、发布动态、添加联系人)

18.4 BottomNavigationView —— 底部导航栏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/bottom_nav_menu"
app:labelVisibilityMode="labeled" />

<!-- res/menu/bottom_nav_menu.xml -->
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_home"
android:icon="@drawable/ic_home"
android:title="首页" />
<item
android:id="@+id/nav_discover"
android:icon="@drawable/ic_discover"
android:title="发现" />
<item
android:id="@+id/nav_mine"
android:icon="@drawable/ic_mine"
android:title="我的" />
</menu>

核心属性

属性说明
app:menu菜单资源
app:labelVisibilityModeauto / labeled / unlabeled / selected
app:itemIconTint图标选中/未选中颜色(ColorStateList)
app:itemTextColor文字选中/未选中颜色

⚠️ 注意

  • 官方建议 3~5 个选项,不要超过 5 个
  • 监听切换:bottomNav.setOnItemSelectedListener { item -> ... }
  • 不建议在代码中手动设置 selectedItemId,可能导致无限回调

18.5 Snackbar —— 轻量提示条

1
2
3
4
5
6
Snackbar.make(rootView, "删除成功", Snackbar.LENGTH_SHORT)
.setAction("撤销") {
// 点击撤销
}
.setActionTextColor(resources.getColor(R.color.accent))
.show()

特点

  • 比 Toast 更强大(支持交互操作)
  • 从底部弹出,会自动向上推 FAB
  • 一次只能显示一个 Snackbar

18.6 TextInputLayout —— 增强输入框

让 EditText 支持 Material Design 风格的浮动标签、错误提示、字符计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:counterEnabled="true"
app:counterMaxLength="20"
app:errorEnabled="true"
android:hint="用户名"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">

<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>

核心属性

属性说明
app:hint浮动标签文字
app:errorEnabled错误提示开关
app:counterEnabled字符计数器开关
app:counterMaxLength最大字符数
app:endIconMode尾部图标模式:password_toggle / clear_text
style样式:OutlinedBox(描边)/ FilledBox(填充)

代码设置错误提示:

1
2
textInputLayout.error = "用户名不能为空"  // 设置错误
textInputLayout.error = null // 清除错误

18.7 Toolbar —— 顶部工具栏

1
2
3
4
5
6
7
8
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:title="标题"
app:titleTextColor="#FFF"
app:navigationIcon="@drawable/ic_back"
app:menu="@menu/toolbar_menu" />

使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Activity 中
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}

// 点击返回按钮
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressedDispatcher.onBackPressed()
return true
}
return super.onOptionsItemSelected(item)
}

⚠️ 注意

  • 使用 Toolbar 时,需要把主题设为 NoActionBarTheme.MaterialComponents.Light.NoActionBar
  • Toolbar 功能比旧 ActionBar 强很多(自定义布局、动画、伸缩)

十九、通用属性速查(适用于所有 View)

以下属性几乎所有控件都能用

属性说明
android:id控件唯一标识(@+id/xxx
android:layout_width宽度:match_parent / wrap_content / 具体值
android:layout_height高度
android:layout_margin外边距(另有 Left/Top/Right/Bottom/Start/End)
android:padding内边距(另有 Left/Top/Right/Bottom/Start/End)
android:background背景(颜色/drawable/shape)
android:visibility可见性:visible / invisible / gone
android:alpha透明度(0~1)
android:elevation阴影高度(Z 轴,API 21+)
android:clickable是否可点击
android:focusable是否可获取焦点
android:contentDescription无障碍辅助描述
android:minWidth / minHeight最小宽高
android:translationX / translationY平移偏移量
android:rotation旋转角度
android:scaleX / scaleY缩放比例

二十、控件选择速查表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
我需要...

显示一段文字 → TextView
用户输入文字 → EditText
用户点击文字按钮 → Button
用户点击图标按钮 → ImageButton
显示图片/头像 → ImageView
多选(如兴趣爱好) → CheckBox
单选题(如性别) → RadioGroup + RadioButton
开关设置 → Switch / SwitchCompat
滑块调节(音量/亮度) → SeekBar
星级评分 → RatingBar
显示加载状态 → ProgressBar
下拉选择(如省份) → Spinner
输入时自动联想 → AutoCompleteTextView
显示网页 → WebView
计时器 → Chronometer
卡片展示内容 → MaterialCardView
标签/筛选标签 → Chip / ChipGroup
浮动操作按钮 → FloatingActionButton
底部导航栏 → BottomNavigationView
轻量操作提示 → Snackbar
增强输入框(浮动标签+错误提示) → TextInputLayout
顶部工具栏 → MaterialToolbar
列表(大量数据) → RecyclerView

📊 最终总结

分类控件学习难度使用频率
文本TextView★☆☆☆☆⭐⭐⭐⭐⭐
文本EditText★★☆☆☆⭐⭐⭐⭐⭐
按钮Button★☆☆☆☆⭐⭐⭐⭐⭐
按钮ImageButton★☆☆☆☆⭐⭐⭐⭐
图片ImageView★★☆☆☆⭐⭐⭐⭐⭐
选择CheckBox★☆☆☆☆⭐⭐⭐⭐
选择RadioButton/RadioGroup★★☆☆☆⭐⭐⭐⭐
选择Switch★☆☆☆☆⭐⭐⭐⭐
选择ToggleButton★☆☆☆☆⭐⭐
选择Spinner★★☆☆☆⭐⭐⭐
进度ProgressBar★☆☆☆☆⭐⭐⭐⭐⭐
进度SeekBar★★☆☆☆⭐⭐⭐
进度RatingBar★☆☆☆☆⭐⭐⭐
输入AutoCompleteTextView★★☆☆☆⭐⭐⭐
容器WebView★★★☆☆⭐⭐⭐⭐
计时Chronometer★☆☆☆☆⭐⭐
MaterialCardView★★☆☆☆⭐⭐⭐⭐⭐
MaterialChip/ChipGroup★★☆☆☆⭐⭐⭐
MaterialFloatingActionButton★☆☆☆☆⭐⭐⭐⭐⭐
MaterialBottomNavigationView★★☆☆☆⭐⭐⭐⭐
MaterialTextInputLayout★★☆☆☆⭐⭐⭐⭐⭐
MaterialToolbar★★☆☆☆⭐⭐⭐⭐⭐

🎯 学习路线建议

1
2
3
4
5
6
 1 天:TextView + EditText + Button + ImageView → 能画基础页面
2 天:CheckBox + RadioButton + Switch + Spinner → 能画表单页面
3 天:ProgressBar + SeekBar + RatingBar → 能画设置/播放器页
4 天:CardView + Chip + FAB + Snackbar → 拥抱 Material Design
5 天:Toolbar + BottomNavigationView + TextInputLayout → 构建完整页面框架
进阶:WebView + AutoCompleteTextView + 自定义 View

动手写,比看十遍记得牢。
建议每天 2~3 个控件,打开 Android Studio 新建一个 Activity,把每个属性都试试。

配合上一篇 Android 常用容器详解 一起看,容器 + 控件 = 完整的 Android UI 能力。


💡 延伸阅读

面向 Android 初学者,用大白话讲清楚每种容器是什么、什么时候用、有什么区别。


先搞懂:什么叫”容器”?

在 Android 里,容器 = ViewGroup。它本身不显示内容,作用是装其他控件(TextView、Button、ImageView 等),并决定它们怎么排列。

1
2
3
4
5
6
7
8
9
10
View(控件基类)
├── TextView ← 显示文字
├── Button ← 按钮
├── ImageView ← 图片
└── ViewGroup(容器基类)
├── LinearLayout
├── RelativeLayout
├── FrameLayout
├── ConstraintLayout
└── ...

一句话:你写的每一个 XML 布局,顶层一定是一个容器。


1. LinearLayout —— 线性布局

特点

所有子控件排成一行(横向)或一列(纵向),按顺序一个接一个。

核心属性

属性说明
android:orientationhorizontal / vertical横向排列还是纵向排列
android:layout_weight数字按权重分配剩余空间

适用场景

  • 表单页面(纵向排列:标题 → 输入框 → 按钮)
  • 标题栏(横向排列:返回按钮 → 标题 → 右侧图标)
  • 列表项布局

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- 纵向排列 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<TextView android:text="用户名" />
<EditText android:hint="请输入" />
<Button android:text="登录" />
</LinearLayout>

<!-- 横向排列,用 weight 均分 -->
<LinearLayout
android:orientation="horizontal">

<Button android:text="取消"
android:layout_width="0dp"
android:layout_weight="1" />

<Button android:text="确定"
android:layout_width="0dp"
android:layout_weight="1" />
</LinearLayout>

⚠️ 注意

  • 嵌套多层 LinearLayout 会导致性能下降(嵌套越深,测量次数越多)
  • 不适合复杂布局

2. RelativeLayout —— 相对布局

特点

子控件相对于父容器或者相对于其他兄弟控件来定位。

核心属性

以父容器为参照:android:layout_alignParentTop, layout_alignParentBottom, layout_alignParentStart, layout_alignParentEnd, layout_centerInParent

以兄弟控件为参照:android:layout_above, layout_below, layout_toStartOf, layout_toEndOf, layout_alignTop, layout_alignBottom

适用场景

  • 元素之间有明确相对关系(A 在 B 右边,C 在 B 下方)
  • 层叠布局(一个控件盖在另一个上面)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<!-- 头像贴左上角 -->
<ImageView
android:id="@+id/iv_avatar"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
... />

<!-- 用户名在头像右边 -->
<TextView
android:id="@+id/tv_name"
android:layout_toEndOf="@id/iv_avatar"
android:layout_alignTop="@id/iv_avatar"
android:text="张三" />

<!-- 简介在用户名下方 -->
<TextView
android:id="@+id/tv_bio"
android:layout_below="@id/tv_name"
android:layout_toEndOf="@id/iv_avatar"
android:text="这个人很懒" />
</RelativeLayout>

⚠️ 注意

  • 控件多了容易混乱(互相依赖),调试困难
  • 现代开发中逐渐被 ConstraintLayout 替代

3. FrameLayout —— 帧布局

特点

所有子控件从左上角开始堆叠,后添加的盖在之前的上面。最简单的容器,性能最佳。

核心属性

  • android:layout_gravity:控制子控件在父容器中的位置(center、start、end、top、bottom 等)
  • android:foreground:在前景层加遮罩(常用于点击态)

适用场景

  • 层叠效果(图片上叠文字、头像上叠红点 badge)
  • 占位容器(Fragment 容器、只放一个子控件时)
  • 标题栏居中文字(返回按钮贴左,标题居中,按钮贴右)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- 图片上叠文字 -->
<FrameLayout
android:layout_width="200dp"
android:layout_height="200dp">

<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@drawable/bg" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="居中文字"
android:textColor="#FFF" />

<!-- 右上角红点 -->
<View
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="end|top"
android:background="@drawable/bg_red_dot" />
</FrameLayout>

✅ 优点

  • 结构简单,性能好
  • 子控件不会互相影响

4. ConstraintLayout —— 约束布局(⭐ 主力推荐)

特点

通过约束(Constraint)关系定位,灵活度最高。是 Google 官方推荐的首选布局。

核心概念

每个子控件的四条边(上下左右)都需要至少一个约束,否则会在编译时报警告。

1
2
3
4
5
6
7
8
9
10
11
┌──────────────────────────────┐
parent
│ ┌───────┐ │
│ │ AA 的约束: │
│ │ │ 左 → parent
│ └───────┘ 上 → parent
│ ↓ │
│ ┌───────┐ B 的约束: │
│ │ B │ 左 → parent 左 │
│ └───────┘ 上 → A 的下边 │
└──────────────────────────────┘

核心属性(全部以 app: 开头)

属性含义
app:layout_constraintStart_toStartOf左边对齐谁的左边
app:layout_constraintEnd_toEndOf右边对齐谁的右边
app:layout_constraintTop_toTopOf上边对齐谁的上边
app:layout_constraintBottom_toBottomOf下边对齐谁的下边
app:layout_constraintTop_toBottomOf上边贴在谁的下边
app:layout_constraintHorizontal_bias水平偏移比例(0~1,0.5=居中)

适用场景

  • 几乎所有布局——Google 推荐用 ConstraintLayout 替代 LinearLayout + RelativeLayout 嵌套
  • 复杂的扁平化布局(一个 ConstraintLayout 搞定过去要嵌套好几层的事)
  • 响应式布局(不同屏幕尺寸自动适应)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<!-- 头像:左上角 -->
<ImageView
android:id="@+id/iv_avatar"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:src="@drawable/ic_avatar" />

<!-- 用户名:头像右边,上对齐 -->
<TextView
android:id="@+id/tv_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="16dp"
app:layout_constraintStart_toEndOf="@id/iv_avatar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/iv_avatar"
android:text="张三"
android:textSize="18sp"
android:textStyle="bold" />

<!-- 简介:用户名下方 -->
<TextView
android:id="@+id/tv_bio"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/tv_name"
app:layout_constraintEnd_toEndOf="@id/tv_name"
app:layout_constraintTop_toBottomOf="@id/tv_name"
android:text="Android 初学者" />

<!-- 底部按钮:始终贴在父容器底部 -->
<Button
android:id="@+id/btn_submit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:text="提交" />
</androidx.constraintlayout.widget.ConstraintLayout>

✅ 优点

  • 扁平化:一层搞定复杂布局
  • 性能好:减少嵌套层级
  • 可视化编辑友好(Android Studio 布局编辑器支持拖拽)

⚠️ 注意

  • 需要添加依赖:implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
  • layout_width="0dp" 表示”由约束决定宽度”,非常常用

5. ScrollView / NestedScrollView —— 滚动容器

特点

当内容超过屏幕高度时,提供上下滚动能力。ScrollView 只能放一个直接子控件,NestedScrollView 支持嵌套滑动(配合 RecyclerView 等)。

适用场景

  • 文章详情页
  • 设置页面
  • 表单超过一屏

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<!-- 这里放很多内容,超出屏幕就可以滚动 -->
<TextView android:text="第一段..." />
<TextView android:text="第二段..." />
<ImageView ... />
<TextView android:text="很长很长..." />
</LinearLayout>
</NestedScrollView>

⚠️ 注意

  • ScrollView 只能有一个直接子控件,所以要放多个东西得包一层 LinearLayout 或 ConstraintLayout
  • NestedScrollView 替代 ScrollView,兼容性更好

6. RecyclerView —— 列表容器(进阶)

特点

  • 专门用于大量数据的列表展示,自带复用机制(滑出屏幕的 item 会被回收给新 item 用)
  • 需要配合 Adapter(适配器)使用

适用场景

  • 聊天列表
  • 商品列表
  • 任何”滚动刷不完”的列表

⚠️ 初学者提示

RecyclerView 比上面几个复杂,需要写 Adapter,建议先掌握前五种再学。


📊 横向对比总结

容器排列方式嵌套性能学习难度推荐指数
LinearLayout线性(横/竖)⭐⭐极易⭐⭐⭐
RelativeLayout相对定位⭐⭐中等⭐⭐
FrameLayout层叠⭐⭐⭐极易⭐⭐⭐
ConstraintLayout约束⭐⭐⭐中等⭐⭐⭐⭐⭐
ScrollView滚动极易⭐⭐⭐⭐
RecyclerView列表复用⭐⭐⭐较难⭐⭐⭐⭐⭐

🎯 实际选择指南

1
2
3
4
5
6
7
8
9
我要做的是...

一个简单的纵向排列(表单/菜单) → LinearLayout vertical
一个横向排列(按钮栏/标签) → LinearLayout horizontal
图片上叠文字/红点/水印 → FrameLayout
标题栏(左按钮 + 中间标题 + 右按钮) → FrameLayout 或 ConstraintLayout
复杂页面、需要适配多种屏幕 → ConstraintLayout(首选)
内容超过一屏需要滚动 → NestedScrollView + 内容容器
大量数据的列表(几十上百条) → RecyclerView

黄金法则

能用一层 ConstraintLayout 搞定的,绝不嵌套多层 LinearLayout。

扁平化布局 = 更少的测量次数 = 更流畅的渲染 = 更好的用户体验。


💡 延伸阅读


本文面向 Android 入门开发者,建议配合 Android Studio 实际编写示例来加深理解。动手敲一遍比看十遍管用!

问题背景

在某管理平台时,遇到了一个奇怪的问题:

同一套 CSS 类 .filter-section,在某些页面生效,在另一些页面却不生效。

通过排查发现,问题出在 Vite + Vue3 + SCSS 的样式加载机制上。本文将详细分析问题根因,并给出解决方案。

现象观察

页面对比

页面.filter-section 是否生效<style> 块内容
pageA.vue✅ 生效有自定义样式
pageB.vue✅ 生效有自定义样式
pageC.vue不生效空的

关键代码

pageC.vue(不生效):

1
2
3
<div class="filter-section">...</div>

<style scoped lang="scss"></style>

pageA.vue(生效):

1
2
3
4
5
6
7
<div class="filter-section">...</div>

<style scoped lang="scss">
.cn-price-prefix::before {
content: "¥";
}
</style>

根因分析

Vite 配置

项目中 vite.config.js 的关键配置:

1
2
3
4
5
6
7
8
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
additionalData: '@use "@/style/style.scss" as *;',
},
},
},

additionalData 的作用是:在每个 <style lang="scss"> 块编译前自动注入代码

核心问题

空的 <style scoped lang="scss"> 块会被 Vite 跳过不处理!

执行流程对比:

有内容的 style 块:

1
2
3
4
5
1. Vite 检测到 <style scoped lang="scss">
2. additionalData 注入 @use "@/style/style.scss" as *;
3. 编译整个 style 块(包括注入的 @use
4. style.scss 中的 .filter-section CSS 规则被输出(带 scoped 属性)
5. 页面元素匹配到样式 ✅

空的 style 块:

1
2
3
4
5
1. Vite 检测到 <style scoped lang="scss"></style>
2. Vite 发现块为空,直接跳过不处理 ❌
3. additionalData 注入的 @use 从未被执行
4. .filter-section CSS 规则没有被输出
5. 页面元素无法匹配样式 ❌

@use vs @import 的区别

加载机制

特性@use@import
加载方式模块化加载,只加载一次文本注入,每次都注入
重复加载同一文件只编译一次多次导入会重复编译
CSS 规则✅ 会导入✅ 会导入
命名空间默认有,可用 as * 取消无命名空间
官方状态✅ 推荐使用❌ 已废弃(deprecated)

常见误区

❌ 错误认知:@use 不会导入 CSS 规则,只导入变量/mixin/function

✅ 正确认知:@use 导入 CSS 规则,它与 @import 的区别在于命名空间和加载机制,而非是否导入 CSS 规则。

作用范围

style.scss 内容:

1
2
3
4
5
6
$theme-color: #26b165; // 变量
.filter-section {
// CSS 规则
width: 100%;
margin-bottom: 20px;
}

使用 @use 导入后:

  • ✅ 可以使用 $theme-color 变量
  • .filter-section 规则会生效

使用 @import 导入后:

  • ✅ 可以使用 $theme-color 变量
  • .filter-section 规则会生效

解决方案

方案一:全局导入(推荐)

main.js 中全局导入样式文件:

1
import "./style/style.scss"; // 全局导入,所有页面生效

优点:

  • .filter-section 只输出一次,不会导致 CSS 重复膨胀
  • 所有页面(包括空 style 块的页面)都能使用
  • 符合 CSS 最佳实践

缺点:

  • 需要额外配置

方案二:添加空规则(临时方案)

给空的 style 块添加任意规则:

1
2
3
4
<style scoped lang="scss">
.empty-rule {
}
</style>

原理:让 Vite 认为 style 块有内容,从而正常编译。

缺点:

  • 每个有非空 style 块的组件都会输出一份 .filter-section 规则(带 scoped 属性)
  • 导致 CSS 重复膨胀
  • 代码不够优雅

方案三:拆分样式文件(进阶方案)

将变量和 CSS 规则分离:

1
2
3
// _variables.scss(只包含变量、mixin、function)
$theme-color: #26b165;
$border-color: #ebeff0;
1
2
3
4
5
// _common.scss(只包含 CSS 规则)
.filter-section {
width: 100%;
margin-bottom: 20px;
}

vite.config.js:

1
2
3
scss: {
additionalData: '@use "@/style/_variables.scss" as *;',
}

main.js:

1
import "./style/_common.scss";

优点:

  • 变量通过 @use 注入,CSS 规则全局导入
  • 不会重复输出 CSS 规则
  • 代码结构清晰

最佳实践总结

样式文件组织

1
2
3
4
src/style/
├── _variables.scss # 变量、mixin、function(通过 @use 注入)
├── _common.scss # 公用 CSS 规则(全局导入)
└── style.scss # 合并文件(包含变量和规则)

Vite 配置

1
2
3
4
5
6
7
8
9
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
// 只注入变量文件
additionalData: '@use "@/style/_variables.scss" as *;',
},
},
},

入口文件配置

1
import "./style/_common.scss"; // 全局导入 CSS 规则

总结

问题根因

空的 <style scoped lang="scss"> 块被 Vite 跳过不处理,导致 additionalData 注入的 @use 从未被执行。

解决方案

推荐使用全局导入方案,在 main.js 中导入包含 CSS 规则的样式文件。

关键要点

  1. @use@import 都会导入 CSS 规则
  2. additionalData 只对非空的 style 块生效
  3. 全局导入是最可靠的样式加载方式
  4. 分离变量和 CSS 规则是最佳实践

附录:调试技巧

查看编译后的 CSS

在浏览器开发者工具中:

  1. 打开 Elements 面板
  2. 查看 .filter-section 元素的样式
  3. 如果没有 .filter-section 相关样式,说明该样式未被输出

验证空 style 块假设

给空的 style 块添加一条规则,观察样式是否生效:

1
2
3
4
5
<style scoped lang="scss">
.debug-rule {
background: red;
}
</style>

如果生效,说明问题确实出在空 style 块上。

本文基于实际项目经验整理,希望能帮助遇到类似问题的开发者。

在 Mac 开发环境中,不同项目对 Node 版本的要求可能不同。本文对比三种主流工具(nvm、n、fnm)的安装、版本切换及使用场景,帮助开发者选择最适合的方案。

工具对比与选择

工具优点缺点适用场景
nvm功能全面,支持多版本隔离安装稍复杂,需配置 Shell需要精细控制版本和全局模块
n安装简单,命令直观无法隔离全局模块,功能较少追求简单快速切换
fnm速度快,支持自动切换项目版本社区相对较小,文档较少喜欢现代化工具且需要自动切换

nvm 使用详解

安装与配置

1
2
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.zshrc # 或 source ~/.bashrc

版本管理

1
2
3
4
5
nvm install 18.0.0      # 安装指定版本
nvm use 18.0.0 # 临时切换(当前终端有效)
nvm alias default 18.0.0 # 永久切换(全局生效)
nvm list # 查看已安装版本
nvm uninstall 18.0.0 # 卸载版本

注意:若切换后重启终端失效,需检查 ~/.zshrc~/.bashrc 是否包含 nvm 初始化脚本。

n 工具使用指南

安装与基础操作

1
2
3
4
5
sudo npm install -g n    # 全局安装n工具
sudo n 18.0.0 # 安装并切换版本
n # 交互式选择版本
n list # 查看已安装版本
n rm 16.13.2 # 删除版本

局限:所有版本共享全局模块,可能导致版本冲突。

fnm 快速入门

安装与配置

1
2
curl -fsSL https://fnm.vercel.app/install | bash
eval "$(fnm env --use-on-cd)" >> ~/.zshrc # 自动切换配置

版本控制

1
2
3
4
fnm install 18.0.0      # 安装版本
fnm use 18.0.0 # 临时切换
fnm default 18.0.0 # 永久切换
fnm ls # 查看版本列表

优势:支持 .node-version.nvmrc 文件自动切换版本。

常见问题解决

nvm 切换后重启终端失效

原因:未设置默认版本或 Shell 未加载 nvm 脚本。

方案

1
2
nvm alias default 18.0.0  # 设置默认版本
echo 'source ~/.nvm/nvm.sh' >> ~/.zshrc # 确保加载nvm

n 工具无法隔离全局模块

建议:对模块隔离要求高的项目优先使用 nvm 或 fnm。

uni.$emit

  • 如果没有提供参数,则移除所有的事件监听器
  • 如果只提供了事件,则移除该事件所有的监听器
  • 如果同时提供了事件与回调,则只移除这个回调的监听器
  • 提供的回调必须跟$on 的回调为同一个才能移除这个回调的监听器

发送

1
uni.$emit("update", { msg: "页面更新" });

监听

1
2
3
uni.$on("update", function (data) {
console.log("监听到事件来自 update ,携带参数 msg 为:" + data.msg);
});

仅监听一次

1
2
3
uni.$once("update", function (data) {
console.log("监听到事件来自 update ,携带参数 msg 为:" + data.msg);
});

注销监听

1
uni.$off([eventName, callback]);

eventChannel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
uni.navigateTo({
url: "pages/test?id=1",
events: {
// 为指定事件添加一个监听器,获取被打开页面传送到当前页面的数据
acceptDataFromOpenedPage: function (data) {
console.log(data);
},
someEvent: function (data) {
console.log(data);
}
// ...
},
success: function (res) {
// 通过eventChannel向被打开页面传送数据
res.eventChannel.emit("acceptDataFromOpenerPage", { data: "test" });
}
});

export default {
// uni.navigateTo 目标页面 pages/test.vue
onLoad: function (option) {
console.log(option.query);
const eventChannel = this.getOpenerEventChannel();
eventChannel.emit("acceptDataFromOpenedPage", { data: "test" });
eventChannel.emit("someEvent", { data: "test" });
// 监听acceptDataFromOpenerPage事件,获取上一页面通过eventChannel传送到当前页面的数据
eventChannel.on("acceptDataFromOpenerPage", function (data) {
console.log(data);
});
}
};

上架谷歌应用市场需要先注册谷歌开发者账号,创建应用,应用审核通过后才能发布应用。在应用审核和发布过程中,需要注意应用的内容、质量、隐私政策等问题。开发者可以随时更新应用,提高应用的质量和用户体验。

申请开发者账号

申请谷歌开发者账号
Google Play 管理中心帮助

准备

Gmail 邮箱

自己申请 Gmail 邮箱,有手机号就可以申请,国内的手机号也照样申请。
注册的时候用 QQ 邮箱/网易邮箱,注册里选 Gmail,按顺序搞就好了,直接用网页注册可能会被提示手机号无法验证。

Visa 信用卡

这是国内最好申请的可以直接用于谷歌开发者账号支付的银行卡。
对,你没看错!谷歌开发者账号是要钱的,25 美元,也就是人民币 160 元左右。
而且每个银行卡只能绑定一个开发者账号,也就是说你用这张 visa 卡开通了一个开发者账号,之后就不能再用这张卡再开新的开发者账户了!

申请流程

要在 Google Play 上发布 Android 应用,您需要创建一个 Google Play 开发者账号

注册 Google Play 开发者帐号

创建应用程序

一旦您已注册 Google Developer 账号,您就可以创建应用程序。要创建应用程序,请转到“创建项目”页面,然后选择“添加应用程序”。这将打开一个新窗口,其中包含有关创建应用程序的说明。按照说明创建应用程序,并为其指定适当的名称和描述。

  1. 打开谷歌开发者控制台
  2. 使用谷歌账号登录
  3. 在左侧菜单栏选择所有应用
  4. 点击“创建应用”
  5. 填写应用详情以及声明等信息即可创建应用

上架应用/游戏

aab 包

从 2021 年 9 月 1 日开始,上架 google play 的包统一都要是 aab 格式,非 aab 格式是无法上传包体的。

在打包时还需要注意 2 点:

  1. 目前 Google Play 管理中心对 Android 10 的目标安全级别要求至少为 31!
  2. 安卓应用程序包应先加固好再进行上传,先上传再加固可能会出现问题。
API 级别要求API 级别要求
Android 8.026
Android 928
Android 1029
Android 1130
Android 1231、32
Android 1333

商店素材

手机和平板可以共用相同的图,不需要额外作图

图片类型格式像素大小张数
应用图标JPEG 或 32 位 PNG512x512 像素上限为 1 MB1
置顶大图JPEG 或 24 位 PNG(不透明)1024x500 像素上限为 1 MB1
手机屏幕截图JPEG 或 24 位 PNG(不透明)介于 320 像素到 3840 像素之间,宽高比为 16:9(针对横屏截图)上限为 8 MB2-8
7 寸平板电脑截图JPEG 或 24 位 PNG(不透明)介于 320 像素到 3840 像素之间,宽高比为 16:9(针对横屏截图)上限为 8 MB1-8
10 寸平板电脑截图JPEG 或 24 位 PNG(不透明)介于 320 像素到 3840 像素之间,宽高比为 16:9(针对横屏截图)上限为 8 MB1-8

上架文案

在 Google play 上需要显示的产品介绍,需求如下

字段说明字符数限制备注
应用名称应用在 Google Play 上显示的名称。上限为 50 个字符您可以为每种语言分别添加一个本地化名称。
简短说明用户通过 Play 商店应用查看您应用的详情页面时最先看到的文字内容。上限为 80 个字符用户可以展开此文本,查看应用的完整说明。
完整说明应用在 Google Play 上显示的说明。上限为 4000 个字符

隐私链接

如果你的应用或游戏会获取一些用户的隐私权限,比如说手机号啊、通讯录之类的,就要记得上传隐私链接,如果你没有服务器可以上架网页的话,也可以用取巧的方法托管到三方平台上。

信息中心

设置内测版本

立即开始测试

设置应用

按照初始设置里的首要步骤一步一步操作即可

设置应用

尤其需要关注的是“内容分级”部分,分级错误会导致应用/游戏审核失败。
Google Play 的“内容分级”是通过自主填写问卷的方式,由系统得出一个分级标准,提审之前可以多次修改,一般同一类的产品分级都是差不多的。

发布应用

发布【正式版】之前也可以考虑先测试,测试部分包括【内部测试】、【封闭式测试】和【开放式测试】,但一般直接发布【正式版】就好了,想要调整什么可以热更新或者发布个新版本。

发布应用

发布成功

游戏/APP 成功上架后,在信息中心就可以看到自己的产品了

应用审核

在提交应用审核之后,谷歌会对应用进行审核。审核通过后,应用就可以上架谷歌应用市场。审核过程通常需要几天到几周的时间,具体时间取决于应用的内容和质量。

apk 的基本编译配置

  • Android Api 版本
    Google 要求 targetApiVersion 支持到最新的 2 个版本(目前是要求 targetApi 29 以上)

  • ABI x64 支持
    如果用到 so 库, 必须支持 x64,通常 abiFilters ‘armeabi-v7a’, ‘arm64-v8a’ 即可

  • 签名有效期
    生成签名的时候需要注意有效期要在 25 年以上

  • App 包名唯一
    上传到 Google Play 的 APP,packageName 必须是没有存在过的

恶意代码检测

  • 权限 : 需合理使用权限,敏感权限可能导致上架被拒
  • 协议通讯安全:API 需使用 https,保护用户的数据安全
  • 马甲包:同一套代码重复上架,会被视为马甲包被封号(有这种需求需要做深度的代码混淆)
  • 内更新:APP 不可有内更新、热修复、跳转三方下载链接等任何动态更改源码的行为(脱离 Google 审核的动态代码都不被接受),更新 APP 唯一途径是在 Google Play 发布新版本
  • 使用隐私数据需披露:必须提供清晰的隐私政策和用户协议,保护用户的隐私和权益。这个更多是靠人工审核,运气不好被查到会被下架
  • 色情、暴力、赌博 这些元素内容就不用多说了,可以看开发者政策
  • 稳定:必须具备基本的功能和稳定性,不得存在严重的 BUG 和漏洞

版本更新

版本号

每一次更新版本都需要提升版本号,不然是无法上传新版本的

App Bundle 探索器

GP 后台的 App Bundle 探索器说白了就是个版本记录器,但这里有个坑是,如果你上传了新版本的包,即使没有发布,你也舍弃了该版本,仍会记录在 App Bundle 探索器里。
你再次上传这个新版本的包,就会提示你“版本号已存在”。此时你需要删除 App Bundle 探索器之前未发布的版本,才能重新上传这个新版本。

后台中英文切换

谷歌开发者后台的语言是跟着谷歌账号走的,所以把谷歌账号的语言改成中文,开发者后台的语言也就自动切换了!

getBoundingClientRect

getBoundingClientRect() 是一个用于获取元素位置和尺寸信息的方法。它返回一个 DOMRect 对象,其提供了元素的大小及其相对于视口的位置,其中包含了以下属性:

  • x:元素左边界相对于视口的 x 坐标。
  • y:元素上边界相对于视口的 y 坐标。
  • width:元素的宽度。
  • height:元素的高度。
  • top:元素上边界相对于视口顶部的距离。
  • right:元素右边界相对于视口左侧的距离。
  • bottom:元素下边界相对于视口顶部的距离。
  • left:元素左边界相对于视口左侧的距离。
1
2
3
4
5
6
7
8
9
10
11
const box = document.getElementById("box");
const rect = box.getBoundingClientRect();

console.log(rect.x); // 元素左边界相对于视口的 x 坐标
console.log(rect.y); // 元素上边界相对于视口的 y 坐标
console.log(rect.width); // 元素的宽度
console.log(rect.height); // 元素的高度
console.log(rect.top); // 元素上边界相对于视口顶部的距离
console.log(rect.right); // 元素右边界相对于视口左侧的距离
console.log(rect.bottom); // 元素下边界相对于视口顶部的距离
console.log(rect.left); // 元素左边界相对于视口左侧的距离

应用场景

这个方法通常用于需要获取元素在视口中的位置和尺寸信息的场景,比如实现拖拽、定位或响应式布局等,兼容性很好,一般用滚动事件比较多。

特殊场景会用上,比如你登录了淘宝的网页,当你下拉滑块的时候,下面的图片不会立即加载出来,有一个懒加载的效果。当上面一张图片没在可视区内时,就开始加载下面的图片。

下面代码就是判断一个容器是否出现在可视窗口内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const box = document.getElementById("box");
window.onscroll = function () {
//window.addEventListener('scroll',()=>{})
console.log(checkInView(box));
};

function checkInView(dom) {
const { top, left, bottom, right } = dom.getBoundingClientRect();
return (
top > 0 &&
left > 0 &&
bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
right <= (window.innerWidth || document.documentElement.clientWidth)
);
}

当容器在可视区域内就输出 true,否则就是 false

intersectionObserver

IntersectionObserver 是一个构造函数,可以接收两个参数,第一个参数是一个回调函数,第二个参数是一个对象。这个方法用于观察元素相交情况,它可以异步地监听一个或多个目标元素与其祖先元素或视口之间的交叉状态。它提供了一种有效的方法来检测元素是否可见或进入视口。

用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 1. 创建一个 `IntersectionObserver` 实例,传入一个回调函数和可选的配置对象。
*/
const observer = new IntersectionObserver(callback, options);
const callback = (entries, observer) => {
// 处理交叉状态变化的回调函数
};

const options = {
// 可选配置
};

/**
* 2. 将要观察的目标元素添加到观察者中
*/
const target = document.querySelector("#targetElement");
observer.observe(target);

/**
* 3. 在回调函数中处理交叉状态的变化
*/
const callback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 元素进入视口
} else {
// 元素离开视口
}
});
};

entries 参数是一个包含每个目标元素交叉状态信息的数组。每个 entry 对象都有以下属性:

  • target:观察的目标元素。
  • intersectionRatio:目标元素与视口的交叉比例,值在 0 到 1 之间。
  • isIntersecting:目标元素是否与视口相交。
  • intersectionRect:目标元素与视口的交叉区域的位置和尺寸信息。

options 对象是可选的配置,其中常用的配置选项包括:

  • root:指定观察器的根元素,默认为视口。
  • rootMargin:设置根元素的外边距,用于扩大或缩小交叉区域。
  • threshold:指定交叉比例的阈值,可以是单个数值或由多个数值组成的数组。

应用场景

IntersectionObserver 适用于实现懒加载、无限滚动、广告展示和可视化统计等场景,同样可以判断元素是否在某一个容器内,不会引起回流。

createNodeIterator

createNodeIterator() 方法是 DOM API 中的一个方法,用于创建一个 NodeIterator 对象,可以用于遍历文档树中的一组 DOM 节点。

通俗一点来讲就是它可以遍历 DOM 结构,把 DOM 变成可遍历的。

应用

遍历 DOM 结构,并且在每个 DOM 节点上都添加了 data-index = "123"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<body>
<div id="app">
<p>hello</p>
<div class="title">标题</div>
<div>
<div class="content">内容</div>
</div>
</div>

<script>
const body = document.getElementsByTagName("body")[0];
const item = document.createNodeIterator(body); //让body变成可遍历的
let root = item.nextNode(); // 下一层

while (root) {
console.log(root);
if (root.nodeType !== 3) {
root.setAttribute("data-index", 123); //给每个节点添加一个属性
}
root = item.nextNode();
}
</script>
</body>

getComputedStyle

getComputedStyle() 是一个可以获取当前元素所有最终使用的 CSS 属性值的方法。返回的是一个 CSS 样式声明对象。

这个方法有两个参数,第一个参数是你想要获取哪个元素的 CSS ,第二个参数是一个伪元素。

用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<style>
#box {
width: 200px;
height: 200px;
background-color: cornflowerblue;
position: relative;
}

#box::after {
content: "";
width: 50px;
height: 50px;
background: #000;
position: absolute;
top: 0;
left: 0;
}
</style>
<body>
<div id="box"></div>

<script>
const box = document.getElementById("box");
const style = window.getComputedStyle(box, "after");

const height = style.getPropertyValue("height");
const width = style.getPropertyValue("width");

console.log(style); // > CSSStyleDeclaration
console.log(width, height); // 50px 50px
</script>
</body>

requestAnimationFrame

requestAnimationFrame() 是一个用于在下一次浏览器重绘之前调用指定函数的方法,它是 HTML5 提供的 API。

与 setInterval 和 setTimeout

  • requestAnimationFrame 的调用频率通常为每秒 60 次。这意味着我们可以在每次重绘之前更新动画的状态,并确保动画流畅运行,而不会对浏览器的性能造成影响。

  • setIntervalsetTimeout 它可以让我们在指定的时间间隔内重复执行一个操作,不会考虑浏览器的重绘,而是按照指定的时间间隔执行回调函数,可能会被延迟执行,从而影响动画的流畅度。

效果对比

设置了两个容器,分别用 requestAnimationFrame()方法和 setTimeout 方法进行平移效果,用 setTimeout 会有卡顿现象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<style>
#box {
width: 200px;
height: 200px;
background-color: cornflowerblue;
}

#box2 {
width: 200px;
height: 200px;
background: #000;
}
</style>
<body>
<div id="box"></div>
<div id="box2"></div>

<script>
let distance = 0;
let box = document.getElementById("box");
let box2 = document.getElementById("box2");

window.addEventListener("click", function () {
requestAnimationFrame(function move() {
box.style.transform = `translateX(${distance++}px)`;
requestAnimationFrame(move); //递归
});

setTimeout(function change() {
box2.style.transform = `translateX(${distance++}px)`;
setTimeout(change, 17);
}, 17);
});
</script>
</body>

普通函数

1
2
3
4
5
const add10 = (x) => x + 10;
const mul10 = (x) => x * 10;
const add100 = (x) => x + 100;

console.log(add10(mul10(add100(10)))); // (10 + 100) * 10 + 10 = 1110

我们想输出的是一个多层函数嵌套的运行结果,即把前一个函数的运行结果赋值给后一个函数。但是如果需要嵌套多层函数,那这种类似于 f(g(h(x)))的写法可读性太差,我们考虑能不能写成(f, g, h)(x)这种简单直观的形式,于是 compose()函数就正好帮助我们实现。

compose

compose 函数是一种函数式编程的概念,它可以将多个函数组合成一个新的函数。在 JavaScript 中,compose 函数的实现通常是使用 reduceRight 方法,从右到左依次执行每个函数。

在函数式编程当中有一个很重要的概念就是函数组合, 实际上就是把处理数据的函数像管道一样连接起来, 然后让数据穿过管道得到最终的结果。

1
compose(add10, mul10, add100)(10);

概念

  • 将需要嵌套执行的函数扁平化处理(平铺)
  • 嵌套执行指的是,一个函数的返回值将作为另一函数的参数
  • 实质是在函数式编程中,将几个具有特点的函数拼凑在一起,让他们结合,形成一个崭新的函数

作用

  • 实现函数式编程中的 Pointfree(无参数), 使我们专注于转换而不是数据

  • 编程更精练、算法更清晰、无参数干扰

  • 任意组合

缺点

不能直观的看到参数

实现

接收多个函数作为参数,从右到左,一个函数的输入为另一个函数的输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function compose(...funcs) {
//=>funcs:传递的函数集合
console.log("---funcs---", funcs);
return function proxy(...args) {
//=>args:第一次调用函数传递的参数集合
let len = funcs.length;
if (len === 0) {
//=>一个函数都不需要执行,直接返回args
return args;
}
if (len === 1) {
//=>只需要执行一个函数,把函数执行,把其结果返回即可
return funcs[0](...args);
}
//方式一
return funcs.reduceRight((x, y) => {
console.log("--x--", x);
console.log("--y--", y);
return typeof x === "function" ? y(x(...args)) : y(x);
});
//方式二
return funcs.reverse().reduce((x, y) => {
console.log("--x--", x);
console.log("--y--", y);
return typeof x === "function" ? y(x(...args)) : y(x);
});
};
}

在这个实现中,compose 函数接受一系列的函数作为参数,然后返回一个新的函数。这个新的函数接受一个初始值,然后使用 reduceRight 方法从右到左依次调用每个函数,每次调用的结果作为下一次调用的输入。

所以,compose(add10, mul10, add100)(10) 的执行过程是:

  1. 首先调用 add100(10),得到结果 110。
  2. 然后调用 mul10(110),得到结果 1100。
  3. 最后调用 add10(1100),得到结果 1110。

这就是 compose 函数的工作原理。