本文详细介绍如何在 Vue 3 + Element Plus 项目中集成天地图 JavaScript API v4.0,涵盖 SDK 加载、地图选点定位、多边形绘制/编辑、轨迹展示等常见场景的完整实现方案。

一、为什么选择天地图

天地图(TianDiTu)是国家地理信息公共服务平台,提供标准的 JavaScript API,相较于国外地图服务具有以下优势:

  • 国内访问稳定,加载速度快
  • 提供卫星影像、混合地图等多种图层
  • API 覆盖标记、折线、多边形、信息窗、比例尺等常用功能
  • 完全免费,无需注册信用卡

二、SDK 动态加载方案

天地图 JS API 通过 CDN 加载,建议在组件内按需动态引入,避免首屏加载不必要的资源。

2.1 加载函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const TIANDITU_TK = "your_api_key_here";

/**
* 动态加载天地图 JavaScript SDK
* SDK 加载完成后 window.T 全局对象可用
*/
const loadTdtScript = () => {
return new Promise((resolve, reject) => {
// 已加载则直接返回,避免重复加载
if (window.T) return resolve();

const script = document.createElement("script");
script.src = `https://api.tianditu.gov.cn/api?v=4.0&tk=${TIANDITU_TK}`;
script.onload = () => resolve();
script.onerror = () => reject(new Error("天地图加载失败"));
document.head.appendChild(script);
});
};

2.2 关键设计点

设计说明
Promise 化将异步加载封装为 Promise,便于 async/await 调用
幂等性加载前检查 window.T,多次调用不会重复创建 <script> 标签
按需加载仅在打开地图弹窗时触发,首页不加载地图 SDK
错误处理onerror 回调 reject,调用方可以捕获加载失败并给出友好提示

三、核心场景实现

3.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
30
31
32
// 初始化地图
const initMap = async () => {
await loadTdtScript();
const T = window.T;

// 创建地图实例
const map = new T.Map("mapContainer");
map.centerAndZoom(new T.LngLat(116.40769, 39.89945), 12);
map.setMapType(TMAP_SATELLITE_MAP); // 卫星影像图

// 点击地图放置标记
map.addEventListener("click", e => {
const { lng, lat } = e.lnglat;
setMarker(map, lng, lat);
});
};

// 放置/移动标记点
let marker = null;
const setMarker = (map, lng, lat) => {
const T = window.T;
if (marker) map.removeOverLay(marker);

const icon = new T.Icon({
iconUrl: markerIconUrl,
iconSize: new T.Point(32, 32),
iconAnchor: new T.Point(16, 32),
});

marker = new T.Marker(new T.LngLat(lng, lat), { icon });
map.addOverLay(marker);
};

关键点

  • 使用 TMAP_SATELLITE_MAP 卫星图层,方便辨认实际地物
  • 标记的 iconAnchor 设为图标底部中心,定位更准确
  • 每次点击先移除旧标记再创建新标记,保证地图上只有一个标记点

3.2 场景二:多边形区域绘制

用户在地图上依次点击顶点,绘制多边形区域,支持撤销和清空操作。

3.2.1 数据结构

1
2
3
4
5
6
7
// 存储所有顶点
const mapPoints = ref([]); // [[lng, lat], [lng, lat], ...]

// 地图实例 + 覆盖物引用
let map = null;
let polygon = null;
let pointMarkers = [];

3.2.2 点击绘制顶点

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
map.addEventListener("click", e => {
const { lng, lat } = e.lnglat;
mapPoints.value.push([lng, lat]);
redrawPolygon(map);
});

const redrawPolygon = map => {
const T = window.T;

// 清除旧覆盖物
if (polygon) map.removeOverLay(polygon);
pointMarkers.forEach(m => map.removeOverLay(m));
pointMarkers = [];

const points = mapPoints.value.map(([lng, lat]) => new T.LngLat(lng, lat));

// 为每个顶点添加标记
pointMarkers = points.map((p, i) => {
return new T.Marker(p, {
title: `顶点${i + 1}`,
icon: new T.Icon({
iconUrl: smallCircleIcon,
iconSize: new T.Point(12, 12),
iconAnchor: new T.Point(6, 6),
}),
});
});
pointMarkers.forEach(m => map.addOverLay(m));

// 至少3个点才绘制多边形
if (points.length >= 3) {
polygon = new T.Polygon(points, {
color: "#409eff", // 边框颜色
weight: 3, // 边框宽度(px)
opacity: 0.8, // 边框不透明度
fillColor: "#79bbff", // 填充颜色
fillOpacity: 0.3, // 填充不透明度
});
map.addOverLay(polygon);
}
};

3.2.3 撤销与清空

1
2
3
4
5
6
7
8
9
10
11
// 撤销上一点
const undoLastPoint = () => {
mapPoints.value.pop();
redrawPolygon(map);
};

// 清空全部
const clearPolygon = () => {
mapPoints.value = [];
redrawPolygon(map);
};

3.2.4 自动计算中心点

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 计算多边形中心经纬度(顶点算术平均)
*/
const calcCenter = points => {
const len = points.length;
if (len === 0) return [0, 0];

const sumLng = points.reduce((s, [lng]) => s + lng, 0);
const sumLat = points.reduce((s, [, lat]) => s + lat, 0);

return [sumLng / len, sumLat / len];
};

3.2.5 已有多边形回显展示

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
const initDetailMap = async boundaryData => {
await loadTdtScript();
const T = window.T;

const map = new T.Map("detailMap");
const points = parseBoundary(boundaryData);

// 绘制多边形
const polygon = new T.Polygon(
points.map(p => new T.LngLat(p.lng, p.lat)),
{ color: "#409eff", weight: 3, opacity: 0.8, fillColor: "#79bbff", fillOpacity: 0.3 },
);
map.addOverLay(polygon);

// 自适应视野
map.setViewport(polygon.getBounds());

// 中心点标记 + 信息窗
const [centerLng, centerLat] = calcCenter(boundaryData);
const centerMarker = new T.Marker(new T.LngLat(centerLng, centerLat), { icon: new T.Icon({ iconUrl: centerPinIcon, iconSize: new T.Point(28, 28), iconAnchor: new T.Point(14, 28) }) });
map.addOverLay(centerMarker);

// 鼠标悬浮信息窗
const infoWin = new T.InfoWindow("");
centerMarker.addEventListener("mouseover", () => {
infoWin.setContent(`<div>经度: ${centerLng.toFixed(6)}</div><div>纬度: ${centerLat.toFixed(6)}</div>`);
map.openInfoWindow(infoWin, centerMarker.getLngLat());
});
centerMarker.addEventListener("mouseout", () => map.closeInfoWindow());
};

关键点

  • setViewport 自动计算最优缩放级别和中心点,使多边形完整显示
  • 中心点使用自定义 SVG 图标,视觉上更突出
  • InfoWindow 用于悬浮展示详细信息,交互体验好
  • 数据驱动视图:先维护状态数组,再统一调用 redrawPolygon() 重绘,撤销/清空操作简洁可靠

3.3 场景三:路径轨迹展示

展示从起点到终点的完整路径,使用折线 + 起终点标记。

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
const initTrackMap = async trajectory => {
await loadTdtScript();
const T = window.T;

const map = new T.Map("trackMap");

// 轨迹折线
const points = trajectory.map(p => new T.LngLat(p.lng, p.lat));
const polyline = new T.Polyline(points, {
color: "#26B165", // 绿色
weight: 4,
opacity: 0.8,
});
map.addOverLay(polyline);

// 起点标记(绿色 "起" 字)
const startIcon = new T.Icon({
iconUrl: generateMarkerSvg("#FFFFFF", "#26B165", "起"),
iconSize: new T.Point(32, 32),
iconAnchor: new T.Point(16, 32),
});
map.addOverLay(new T.Marker(points[0], { icon: startIcon }));

// 终点标记(红色 "终" 字)
const endIcon = new T.Icon({
iconUrl: generateMarkerSvg("#FFFFFF", "#FF4C4C", "终"),
iconSize: new T.Point(32, 32),
iconAnchor: new T.Point(16, 32),
});
map.addOverLay(new T.Marker(points[points.length - 1], { icon: endIcon }));

// 自适应视野
map.setViewport(polyline.getBounds());

// 比例尺控件
map.addControl(new T.Control.Scale());
};

起/终点 SVG 图标生成函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 生成带文字的圆形标记 SVG Data URI
* @param {string} fillColor 填充色
* @param {string} strokeColor 描边色
* @param {string} text 文字
*/
const generateMarkerSvg = (fillColor, strokeColor, text) => {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40">
<circle cx="16" cy="14" r="12" fill="${fillColor}" stroke="${strokeColor}" stroke-width="2.5"/>
<text x="16" y="19" text-anchor="middle" fill="${strokeColor}" font-size="14" font-weight="bold">${text}</text>
<polygon points="10,26 16,38 22,26" fill="${strokeColor}"/>
</svg>`;
return "data:image/svg+xml," + encodeURIComponent(svg);
};

关键点

  • 使用内联 SVG Data URI 生成自定义标记图标,无需额外静态资源
  • setViewport 配合 getBounds() 自动适配,确保整条轨迹完整可见
  • 比例尺控件帮助用户直观感知距离

四、地图实例生命周期管理

每个使用地图的组件都应维护独立的实例引用,确保组件销毁时正确清理:

1
2
3
4
5
6
7
8
9
10
11
12
// 模块级变量,存储地图实例引用
let mapInstance = null;
let markerInstance = null;

// onUnmounted / 弹窗关闭时清理
const destroyMap = () => {
if (mapInstance) {
mapInstance.clearOverLays(); // 清除所有覆盖物
mapInstance = null;
}
markerInstance = null;
};

生命周期对照表

事件操作
弹窗 @opened调用 initMap() 创建地图
弹窗 @closed调用 destroyMap() 销毁实例
组件 onUnmounted清理可能残留的地图实例

五、API 能力速查

API用途关键参数
T.Map(containerId)创建地图实例DOM 容器 id
map.centerAndZoom(center, zoom)设置中心点和缩放T.LngLat, 缩放级别 1-18
map.setMapType(type)设置图层TMAP_NORMAL_MAP / TMAP_SATELLITE_MAP / TMAP_HYBRID_MAP
T.Marker(lngLat, opts)添加标记点坐标、T.Icon 图标配置
T.Polyline(points, style)绘制折线坐标数组、颜色、线宽
T.Polygon(points, style)绘制多边形坐标数组、边框/填充样式
T.InfoWindow(content)信息窗HTML 内容字符串
map.setViewport(bounds)自适应视野polyline.getBounds() / polygon.getBounds()
T.Control.Scale()比例尺控件添加到 map.addControl()
map.addEventListener(event, fn)事件监听clickmouseovermouseout
map.clearOverLays()清除所有覆盖物

六、踩坑记录与最佳实践

6.1 弹窗中地图初始化时机

问题:在 el-dialog 中使用天地图,如果在 onMounted 中初始化,此时弹窗 DOM 可能还未渲染,地图容器 div 的实际宽高为 0,导致地图无法正常显示。

解决:监听弹窗的 @opened 事件,此时 DOM 已完全渲染,再初始化地图:

1
2
3
<el-dialog @opened="initMap" @closed="destroyMap">
<div id="mapContainer" style="width:100%;height:480px"></div>
</el-dialog>

6.2 多次打开弹窗地图未重绘

问题:关闭弹窗后再次打开,地图容器中残留上次的 DOM 节点,两次初始化互相干扰。

解决:关闭弹窗时彻底销毁地图实例和 DOM:

1
2
3
4
5
6
7
8
9
const destroyMap = () => {
if (map) {
map.clearOverLays();
map = null;
}
// 手动清空容器 DOM(天地图可能残留节点)
const container = document.getElementById("mapContainer");
if (container) container.innerHTML = "";
};

6.3 经纬度顺序陷阱

问题:天地图 API 中 LngLat 的构造函数参数顺序是 (经度, 纬度),即 (lng, lat),而非更常见的 (lat, lng)。如果搞反会导致标记点飞到错误位置。

正确写法

1
new T.LngLat(lng, lat); // ✅ 经度在前,纬度在后

6.4 多边形数据格式兼容

问题:后端返回的多边形数据格式可能不统一,有的是 [[lng, lat]] 二维数组,有的是 [{lng, lat}] 对象数组。

解决:编写兼容解析函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const parseBoundary = data => {
if (!data || !data.length) return [];

// 格式1: [[lng, lat], [lng, lat]]
if (Array.isArray(data[0])) {
return data.map(([lng, lat]) => ({ lng, lat }));
}

// 格式2: [{lng, lat}] 或 [{longitude, latitude}]
return data.map(p => ({
lng: p.lng ?? p.longitude,
lat: p.lat ?? p.latitude,
}));
};

6.5 API Key 安全防护

注意:天地图 API Key 不应直接硬编码在前端代码中。建议方案:

  • 将 Key 存储在 .env 环境变量中:VITE_TIANDITU_TK=your_key
  • 使用 import.meta.env.VITE_TIANDITU_TK 读取
  • 生产环境可通过后端接口动态获取,进一步降低泄漏风险

七、进阶封装建议

当项目中有多个页面使用天地图时,建议将通用逻辑抽取为 Composable:

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
// composables/useTdtMap.js
export function useTdtMap(containerId, options = {}) {
const map = ref(null);
const markers = ref([]);
const polyline = ref(null);
const polygon = ref(null);

const init = async () => {
await loadTdtScript();
const T = window.T;
map.value = new T.Map(containerId);
map.value.centerAndZoom(new T.LngLat(options.center?.[0] ?? 116.4, options.center?.[1] ?? 39.9), options.zoom ?? 12);
if (options.mapType) map.value.setMapType(options.mapType);
};

const addMarker = (lng, lat, iconUrl) => {
const T = window.T;
const m = new T.Marker(new T.LngLat(lng, lat), {
icon: iconUrl ? new T.Icon({ iconUrl, iconSize: new T.Point(32, 32), iconAnchor: new T.Point(16, 32) }) : undefined,
});
map.value.addOverLay(m);
markers.value.push(m);
return m;
};

const drawPolyline = (points, style = {}) => {
const T = window.T;
polyline.value = new T.Polyline(
points.map(p => new T.LngLat(p[0], p[1])),
{ color: "#26B165", weight: 4, opacity: 0.8, ...style },
);
map.value.addOverLay(polyline.value);
map.value.setViewport(polyline.value.getBounds());
};

const drawPolygon = (points, style = {}) => {
const T = window.T;
polygon.value = new T.Polygon(
points.map(p => new T.LngLat(p[0], p[1])),
{ color: "#409eff", weight: 3, opacity: 0.8, fillColor: "#79bbff", fillOpacity: 0.3, ...style },
);
map.value.addOverLay(polygon.value);
map.value.setViewport(polygon.value.getBounds());
};

const clearAll = () => {
if (map.value) map.value.clearOverLays();
markers.value = [];
polyline.value = null;
polygon.value = null;
};

const destroy = () => {
clearAll();
map.value = null;
const el = document.getElementById(containerId);
if (el) el.innerHTML = "";
};

onUnmounted(destroy);

return {
map,
markers,
polyline,
polygon,
init,
addMarker,
drawPolyline,
drawPolygon,
clearAll,
destroy,
};
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<el-dialog v-model="visible" @opened="map.init" @closed="map.destroy">
<div id="myMap" style="height:500px"></div>
</el-dialog>
</template>

<script setup>
import { useTdtMap } from "@/composables/useTdtMap";

const map = useTdtMap("myMap", {
center: [116.4, 39.9],
zoom: 12,
mapType: TMAP_SATELLITE_MAP,
});
</script>

八、总结

在 Vue 3 项目中落地天地图,核心在于以下几点:

  1. 按需加载:通过动态注入 <script> 标签实现 SDK 按需加载,避免首屏性能损耗
  2. 生命周期对齐:弹窗 @opened 初始化、@closed 销毁,避免地图渲染异常
  3. 数据驱动视图:以状态数组为单一数据源,通过重绘函数统一渲染覆盖物,撤销/清空操作简洁可靠
  4. 适度抽象:当使用场景超过 2 个时,建议抽取 Composable 统一管理地图生命周期和操作

本文覆盖了选点定位、多边形绘制/编辑、路径轨迹展示三个典型场景,掌握了这些模式后,扩展到热力图、聚合点等高级功能也会更加得心应手。

本文基于天地图 JavaScript API v4.0 编写。

本文是 Compose 系列配套参考手册,包含每个控件的完整参数、示例和注意事项。配合 Compose 从 0 到 1 食用更佳。


前言:Compose 控件体系概览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Composable 函数(所有 UI 都是函数)
├── Text(文本)
├── Button(按钮)
├── TextField / OutlinedTextField(输入框)
├── Image(图片)
├── Checkbox(复选框)
├── Switch(开关)
├── RadioButton(单选按钮)
├── Slider(滑动条)
├── Column(纵向布局)
├── Row(横向布局)
├── Box(层叠布局)
├── LazyColumn / LazyRow(列表控件)
├── Scaffold(页面骨架)
│ ├── TopAppBar(顶部栏)
│ ├── BottomAppBar / NavigationBar(底部栏)
│ └── FloatingActionButton(浮动按钮)
└── Modifier(外观修饰,适用于所有控件)

Modifier 是所有控件共用的外观修饰系统,详见第十四章。

Text —— 文本

作用

显示文字。Compose 中最基础的控件,替代传统 TextView

核心属性

参数类型说明
textString显示的文本内容
modifierModifier外观修饰(大小、边距、背景等)
colorColor文字颜色,如 Color.BlueColor(0xFF333333)
fontSizeTextUnit字号,如 16.sp
fontStyleFontStyleFontStyle.Normal / FontStyle.Italic
fontWeightFontWeight字重:Thin / Light / Normal / Medium / Bold / Black
fontFamilyFontFamily字体,如 FontFamily.Default / FontFamily.Monospace
letterSpacingTextUnit字母间距,如 1.sp
textDecorationTextDecorationNone / Underline / LineThrough
textAlignTextAlign对齐方式:Left / Center / Right / Justify / Start / End
lineHeightTextUnit行高,如 24.sp
maxLinesInt最大行数,超出后按 overflow 处理
overflowTextOverflow超出处理:Ellipsis(省略号)/ Clip(裁剪)/ Visible(可见)
softWrapBoolean是否自动换行,默认 true
styleTextStyle统一样式对象,可一次性设置多个文字属性
onTextLayout(TextLayoutResult) -> Unit文字布局完成回调

示例

1
2
3
4
5
6
7
8
Text(
text = "Hello Compose",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.Blue,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)

适用场景

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

⚠️ 注意

  • fontSize 必须带单位 .sp,否则编译报错
  • overflow = TextOverflow.Ellipsis 只在 maxLines 设置后生效
  • 需要渐变文字或复杂样式时,使用 buildAnnotatedString + ClickableText

Button —— 按钮

作用

用户点击触发操作。Compose 中最核心的交互控件,Material 3 风格。

核心属性

参数类型说明
onClick() -> Unit点击回调
modifierModifier外观修饰
enabledBoolean是否可点击,false 时变灰,默认 true
colorsButtonColors颜色配置,由 ButtonDefaults.buttonColors() 创建
elevationButtonElevation阴影高度,由 ButtonDefaults.buttonElevation() 创建
shapeShape按钮形状,如 RoundedCornerShape(8.dp)
borderBorderStroke?边框,如 BorderStroke(1.dp, Color.Gray)
contentPaddingPaddingValues内容内边距,默认 ButtonDefaults.ContentPadding
interactionSourceMutableInteractionSource交互状态源(高级用法)
content@Composable RowScope.() -> Unit按钮内部内容(通常是 Text + 图标组合)

示例

1
2
3
4
5
6
7
8
Button(
onClick = { },
colors = ButtonDefaults.buttonColors(
containerColor = Color.Blue
)
) {
Text("点击我")
}

文字按钮(TextButton)

1
2
3
TextButton(onClick = { }) {
Text("取消")
}

描边按钮(OutlinedButton)

1
2
3
OutlinedButton(onClick = { }) {
Text("了解更多")
}

适用场景

  • 提交表单、页面跳转、确认/取消操作等点击交互

⚠️ 注意

  • content 中使用 RowScope,可以横向排列多个元素(如图标 + 文字)
  • Material 3 默认没有阴影,区别于传统 MaterialButton
  • ButtonDefaults.buttonColors() 可配置:containerColorcontentColordisabledContainerColordisabledContentColor

TextField —— 输入框

作用

用户输入文字。Material 3 提供两种样式:TextField(填充)和 OutlinedTextField(描边)。

核心属性

参数类型说明
valueString当前输入内容,必填
onValueChange(String) -> Unit输入变化回调,必填
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
readOnlyBoolean是否只读,默认 false
label@Composable (() -> Unit)?浮动标签,如 { Text("用户名") }
placeholder@Composable (() -> Unit)?占位文字,如 { Text("请输入") }
leadingIcon@Composable (() -> Unit)?左侧图标
trailingIcon@Composable (() -> Unit)?右侧图标(常用于清除按钮)
isErrorBoolean是否显示错误状态,默认 false
supportingText@Composable (() -> Unit)?底部辅助文字(错误提示 / 字符计数)
singleLineBoolean是否单行,默认 false
maxLinesInt最大行数
keyboardOptionsKeyboardOptions键盘类型:KeyboardOptions(keyboardType = KeyboardType.Password)
keyboardActionsKeyboardActions键盘动作:KeyboardActions(onDone = { ... })
colorsTextFieldColors颜色配置
textStyleTextStyle文字样式
visualTransformationVisualTransformation视觉变换,如 PasswordVisualTransformation()
shapeShape形状

KeyboardType 常用值

场景
KeyboardType.Text普通文本
KeyboardType.Password密码
KeyboardType.Number数字
KeyboardType.Decimal带小数点数字
KeyboardType.Phone电话号码
KeyboardType.Email邮箱地址
KeyboardType.Uri网址

示例

1
2
3
4
5
6
7
8
9
var text by remember { mutableStateOf("") }

TextField(
value = text,
onValueChange = { text = it },
label = { Text("用户名") },
placeholder = { Text("请输入") },
singleLine = true
)

适用场景

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

⚠️ 注意

  • valueonValueChange必填参数,缺一不可
  • 必须配合 remember { mutableStateOf("") } 使用,否则无法输入
  • 密码框用 visualTransformation = PasswordVisualTransformation()
  • OutlinedTextField 参数与 TextField 几乎相同,只是外观为描边样式

Image —— 图片

作用

显示图片,支持本地资源、网络图片、位图等。

核心属性

参数类型说明
painterPainter图片资源,painterResource(R.drawable.xxx)
contentDescriptionString?无障碍描述,必填(纯装饰可传 null
modifierModifier外观修饰
alignmentAlignment图片在控件内的对齐方式,默认 Alignment.Center
contentScaleContentScale缩放方式(见下表)
alphaFloat透明度,0f ~ 1f,默认 1f
colorFilterColorFilter?颜色滤镜,如 ColorFilter.tint(Color.Red)

ContentScale 常用值

效果
Crop裁剪填满,保持比例(类似 centerCrop)
Fit等比缩放,完全显示(类似 fitCenter)
FillBounds拉伸填满,不保持比例(类似 fitXY)
FillWidth宽度填满,高度按比例
FillHeight高度填满,宽度按比例
None原始尺寸,不缩放

示例

1
2
3
4
5
6
Image(
painter = painterResource(id = R.drawable.ic_launcher),
contentDescription = "应用图标",
modifier = Modifier.size(100.dp),
contentScale = ContentScale.Crop
)

适用场景

  • 头像、商品图、Banner、图标、背景图等

⚠️ 注意

  • contentDescription 应始终提供有意义的描述,纯装饰性图片传 null
  • 加载网络图片需要引入图片加载库(Coil / Glide),painterResource 只能加载本地资源
  • 圆形头像裁剪用 Modifier.clip(CircleShape)

Checkbox —— 复选框

作用

多选开关,用户勾选/取消勾选。

核心属性

参数类型说明
checkedBoolean是否勾选
onCheckedChange((Boolean) -> Unit)?勾选状态变化回调
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
colorsCheckboxColors颜色配置,CheckboxDefaults.colors()

示例

1
2
3
4
5
6
var checked by remember { mutableStateOf(false) }

Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = checked, onCheckedChange = { checked = it })
Text("我同意用户协议")
}

三态复选框(TriStateCheckbox)

1
2
3
4
5
6
7
8
9
var state by remember { mutableStateOf(ToggleableState.Off) }

TriStateCheckbox(state = state, onClick = {
state = when (state) {
ToggleableState.Off -> ToggleableState.Indeterminate
ToggleableState.Indeterminate -> ToggleableState.On
ToggleableState.On -> ToggleableState.Off
}
})

适用场景

  • 协议勾选、多选列表、设置项开关

⚠️ 注意

  • onCheckedChange 是可空的,设为 null 可禁用交互(但不改变外观)
  • 通常搭配 Row + Text 组成完整的选项行
  • TriStateCheckbox 支持三个状态:未选 / 半选 / 全选,适合全选场景

Switch —— 开关

作用

开关控件,用于切换两种互斥状态(如开关某项功能)。

核心属性

参数类型说明
checkedBoolean是否开启
onCheckedChange((Boolean) -> Unit)?状态变化回调
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
colorsSwitchColors颜色配置,SwitchDefaults.colors()
thumbContent@Composable (() -> Unit)?滑块内部内容(可放图标)

示例

1
2
3
var isOn by remember { mutableStateOf(true) }

Switch(checked = isOn, onCheckedChange = { isOn = it })

带图标的开关

1
2
3
4
5
6
7
Switch(
checked = isWiFi,
onCheckedChange = { isWiFi = it },
thumbContent = if (isWiFi) {
{ Icon(Icons.Default.Wifi, contentDescription = null, modifier = Modifier.size(16.dp)) }
} else null
)

适用场景

  • 设置页功能开关(WiFi、蓝牙、通知等)

⚠️ 注意

  • SwitchCheckbox 语义不同:Switch 表示即时生效的状态切换,Checkbox 表示需要提交的选项
  • colors 可配置:checkedThumbColorcheckedTrackColoruncheckedThumbColoruncheckedTrackColor

RadioButton —— 单选按钮

作用

多选一,用户从一组选项中选择一个。

核心属性

参数类型说明
selectedBoolean是否被选中
onClick(() -> Unit)?点击回调
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
colorsRadioButtonColors颜色配置,RadioButtonDefaults.colors()

示例

1
2
3
4
5
6
7
8
9
10
11
12
val options = listOf("男", "女", "保密")
var selected by remember { mutableStateOf(options[0]) }

options.forEach { option ->
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = (option == selected),
onClick = { selected = option }
)
Text(option)
}
}

适用场景

  • 性别选择、支付方式选择、任何互斥选项场景

⚠️ 注意

  • RadioButton 本身不包含文字,需要手动组合 Row + Text
  • 和传统的 RadioGroup 不同,Compose 中需要手动管理互斥逻辑(通过状态变量)
  • colors 可配置:selectedColorunselectedColordisabledSelectedColordisabledUnselectedColor

Slider —— 滑动条

作用

通过拖动滑块在连续范围内选择一个值。

核心属性

参数类型说明
valueFloat当前值
onValueChange(Float) -> Unit拖动回调
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
valueRangeClosedFloatingPointRange<Float>取值范围,如 0f..100f
stepsInt分段数,0 表示连续,1 表示 1 段
colorsSliderColors颜色配置,SliderDefaults.colors()
onValueChangeFinished(() -> Unit)?拖动结束回调

示例

1
2
3
4
5
6
7
8
9
var sliderValue by remember { mutableStateOf(0.5f) }

Slider(
value = sliderValue,
onValueChange = { sliderValue = it },
valueRange = 0f..1f
)

Text("当前值:${"%.0f".format(sliderValue * 100)}%")

带刻度的 Slider

1
2
3
4
5
6
7
8
var volume by remember { mutableStateOf(5f) }

Slider(
value = volume,
onValueChange = { volume = it },
valueRange = 0f..10f,
steps = 9 // 10 个档位需要 9 个分段
)

适用场景

  • 音量调节、亮度调节、进度条、价格区间选择

⚠️ 注意

  • steps = 0 表示连续拖动,steps = n 表示分成 n+1 档
  • onValueChangeFinished 适合在拖动结束时触发保存或网络请求
  • 需要显示范围标签时,配合 Row + Text 放在 Slider 两端

Column —— 纵向布局

作用

子元素纵向依次排列。等同于传统 LinearLayout(orientation=vertical)

核心属性

参数类型说明
modifierModifier外观修饰
verticalArrangementArrangement.Vertical垂直排列方式(见下表)
horizontalAlignmentAlignment.Horizontal水平对齐方式:Start / CenterHorizontally / End
content@Composable ColumnScope.() -> Unit子元素内容

Arrangement.Vertical 常用值

效果
Arrangement.Top顶部对齐(默认)
Arrangement.Center垂直居中
Arrangement.Bottom底部对齐
Arrangement.SpaceBetween两端对齐,中间均匀留空
Arrangement.SpaceAround每个元素两侧有相同间距
Arrangement.SpaceEvenly所有间距相等
Arrangement.spacedBy(8.dp)每个元素之间固定间距

示例

1
2
3
4
5
6
7
8
9
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("第一行")
Text("第二行")
Text("第三行")
}

适用场景

  • 表单页面、设置列表、任何需要纵向排列内容的场景

⚠️ 注意

  • verticalArrangement 只在 Column 高度大于子元素总高度时生效
  • 子元素默认靠左对齐,通过 horizontalAlignment 控制水平位置
  • 使用 Modifier.weight(1f) 可以让子元素按比例分配剩余空间

Row —— 横向布局

作用

子元素横向依次排列。等同于传统 LinearLayout(orientation=horizontal)

核心属性

参数类型说明
modifierModifier外观修饰
horizontalArrangementArrangement.Horizontal水平排列方式(同 Column 中的 Arrangement 值,方向改为水平)
verticalAlignmentAlignment.Vertical垂直对齐方式:Top / CenterVertically / Bottom
content@Composable RowScope.() -> Unit子元素内容

示例

1
2
3
4
5
6
7
8
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("左边")
Text("右边")
}

适用场景

  • 工具栏、操作栏、表单行、任何需要横向排列的场景

⚠️ 注意

  • horizontalArrangement 只在 Row 宽度大于子元素总宽度时生效
  • 子元素默认顶部对齐,通过 verticalAlignment 控制垂直位置
  • 常用 Modifier.weight(1f) 让文字填充剩余空间(搭配 TextOverflow.Ellipsis

Box —— 层叠布局

作用

子元素层叠排列,后写的盖在先写的上面。等同于传统 FrameLayout

核心属性

参数类型说明
modifierModifier外观修饰
contentAlignmentAlignment所有子元素的对齐方式,默认 TopStart
propagateMinConstraintsBoolean是否传递最小约束给子元素,默认 false
content@Composable BoxScope.() -> Unit子元素内容

子元素独立对齐

Box 内,子元素可通过 Modifier.align() 独立控制自己的对齐位置:

位置
Alignment.TopStart左上
Alignment.TopCenter上中
Alignment.TopEnd右上
Alignment.CenterStart左中
Alignment.Center正中
Alignment.CenterEnd右中
Alignment.BottomStart左下
Alignment.BottomCenter下中
Alignment.BottomEnd右下

示例

1
2
3
4
5
6
7
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center
) {
Image(painter = painterResource(R.drawable.bg), contentDescription = null)
Text("盖在图片上的文字", color = Color.White)
}

不同位置叠加

1
2
3
4
5
Box(modifier = Modifier.fillMaxSize()) {
Text("左上角", modifier = Modifier.align(Alignment.TopStart))
Text("正中间", modifier = Modifier.align(Alignment.Center))
Text("右下角", modifier = Modifier.align(Alignment.BottomEnd))
}

适用场景

  • 图文叠加(文字盖在图片上)、角标(Badge)、加载覆盖层、水印

⚠️ 注意

  • Box 的大小由最大的子元素决定(除非指定了固定尺寸)
  • 子元素默认对齐左上角,通过 contentAlignment 统一设置或 Modifier.align() 单独设置
  • BoxScope 提供了 matchParentSize() 修饰符,让子元素匹配 Box 的大小

LazyColumn / LazyRow —— 列表控件

作用

高性能列表,自带回收复用机制,只渲染屏幕上可见的 item。替代传统 RecyclerView

核心属性

参数类型说明
modifierModifier外观修饰
stateLazyListState列表状态(滚动位置、首个可见项等)
contentPaddingPaddingValues列表内边距
reverseLayoutBoolean是否反转布局,默认 false
verticalArrangement / horizontalArrangementArrangement.Vertical / Arrangement.Horizontalitem 间距排列方式
flingBehaviorFlingBehavior滑动惯性行为
userScrollEnabledBoolean是否允许用户滚动,默认 true
contentLazyListScope.() -> Unit列表内容

LazyListScope 常用方法

方法说明
item { }添加单个 item
items(count) { index -> }添加 count 个 item
items(list) { item -> }遍历集合添加 item
itemsIndexed(list) { index, item -> }遍历集合(带索引)
stickyHeader { }粘性头部

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val items = List(1000) { "第 ${it + 1} 项" }

LazyColumn {
items(items) { item ->
Text(
text = item,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
fontSize = 18.sp
)
Divider()
}
}

横向列表

1
2
3
4
5
6
7
8
9
10
11
12
LazyRow {
items(20) { index ->
Box(
modifier = Modifier
.size(100.dp)
.padding(8.dp)
.background(Color.Gray)
) {
Text("$index")
}
}
}

带头部的列表

1
2
3
4
5
LazyColumn {
item { Text("头部", fontSize = 24.sp) }
items(dataList) { item -> Text(item) }
item { Text("底部", fontSize = 12.sp) }
}

适用场景

  • 聊天列表、商品列表、新闻列表、任何数据量较大需要滚动的场景

⚠️ 注意

  • 不要LazyColumn 外套 ColumnRow,会导致性能问题和布局异常
  • LazyVerticalGrid 可实现网格列表,LazyHorizontalGrid 可实现横向网格
  • 使用 rememberLazyListState() 可程序化控制滚动位置
  • contentPadding + Arrangement.spacedBy() 可控制列表边距和 item 间距

Scaffold —— 页面骨架

作用

快速搭建标准 App 页面结构:顶部栏 + 内容区 + 底部栏 + 浮动按钮。

核心属性

参数类型说明
modifierModifier外观修饰
topBar@Composable () -> Unit顶部栏,通常放 TopAppBar
bottomBar@Composable () -> Unit底部栏,放 BottomAppBarNavigationBar
floatingActionButton@Composable () -> Unit浮动按钮,通常放 FloatingActionButton
floatingActionButtonPositionFabPositionFAB 位置:FabPosition.Center / FabPosition.End
snackbarHost@Composable () -> UnitSnackbar 宿主
containerColorColor内容区背景色
content@Composable (PaddingValues) -> Unit页面主体内容,必须在内容上加上 innerPadding

示例

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
Scaffold(
topBar = {
TopAppBar(
title = { Text("我的应用") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Blue,
titleContentColor = Color.White
)
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = "首页") },
label = { Text("首页") },
selected = true,
onClick = { }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Person, contentDescription = "我的") },
label = { Text("我的") },
selected = false,
onClick = { }
)
}
},
floatingActionButton = {
FloatingActionButton(onClick = { }) {
Icon(Icons.Default.Add, contentDescription = "添加")
}
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Text("页面内容在这里")
}
}

TopAppBar 核心属性

参数类型说明
title@Composable () -> Unit标题内容
modifierModifier外观修饰
navigationIcon@Composable () -> Unit左侧导航图标(返回键)
actions@Composable RowScope.() -> Unit右侧操作按钮组
colorsTopAppBarColors颜色配置
scrollBehaviorTopAppBarScrollBehavior?滚动行为
参数类型说明
modifierModifier外观修饰
containerColorColor背景色
contentColorColor内容主色
tonalElevationDp色调高度
content@Composable RowScope.() -> Unit导航项内容

FloatingActionButton 核心属性

参数类型说明
onClick() -> Unit点击回调
modifierModifier外观修饰
shapeShape形状,默认 CircleShape
containerColorColor背景色
contentColorColor内容色
content@Composable () -> Unit内部内容

适用场景

  • 几乎任何需要标准 App 页面的场景(首页、设置、个人中心等)

⚠️ 注意

  • **必须使用 innerPadding**,否则内容会被 TopAppBar / BottomBar 遮挡
  • Scaffold 自身不滚动,列表内容需要放在 content 内部的 LazyColumn
  • BottomAppBarNavigationBar 的区别:前者是通用底部栏,后者专用于底部导航
  • FloatingActionButtonshape 默认圆形,可用 RoundedCornerShape(16.dp) 改为圆角矩形

Modifier 速查表

作用

Modifier 是所有 Compose 控件共用的外观修饰系统,控制控件的尺寸、边距、背景、形状、点击等。

常用 Modifier 速查

Modifier类别说明
fillMaxWidth()尺寸宽度填满父容器
fillMaxHeight()尺寸高度填满父容器
fillMaxSize()尺寸宽高都填满
size(width, height)尺寸固定宽高,如 size(100.dp, 50.dp)
width(dp)尺寸固定宽度,如 width(200.dp)
height(dp)尺寸固定高度,如 height(48.dp)
wrapContentWidth()尺寸宽度包裹内容
wrapContentHeight()尺寸高度包裹内容
wrapContentSize()尺寸包裹内容尺寸
padding(all)内边距四边相同内边距,如 padding(16.dp)
padding(horizontal, vertical)内边距水平和垂直内边距
padding(start, top, end, bottom)内边距分别设置四边内边距
background(color)外观背景色,如 background(Color.Red)
background(color, shape)外观带形状的背景色
clip(shape)外观裁剪形状,RoundedCornerShape(12.dp) / CircleShape
border(width, color)外观边框,border(1.dp, Color.Gray)
border(width, color, shape)外观带形状的边框
shadow(elevation, shape)外观阴影,shadow(4.dp, RoundedCornerShape(8.dp))
alpha(float)外观透明度,0f(全透明) ~ 1f(不透明)
clickable { }交互点击事件
combinedClickable(onClick, onLongClick)交互点击 + 长按
weight(float)布局按权重分配空间(Row/Column 内使用)
offset(x, y)布局偏移位置,不影响布局计算
align(alignment)布局在父容器中的对齐方式(Box 内使用)
scrollable(state, orientation)滚动使控件可滚动
verticalScroll(rememberScrollState())滚动垂直滚动(Column 内使用)
horizontalScroll(rememberScrollState())滚动水平滚动(Row 内使用)
animateContentSize()动画内容尺寸变化时自动动画过渡
testTag(string)测试测试标签

综合示例

1
2
3
4
5
6
7
8
9
10
11
Box(
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Blue)
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
.clickable { },
contentAlignment = Alignment.Center
) {
Text("点我", color = Color.White)
}

⚠️ 注意

  • Modifier 顺序很重要:先 paddingbackground,背景色包含 padding 区域;反之则不包含
  • 链式调用顺序是从外到内包裹的:Modifier.padding(16.dp).background(Color.Red) 等效于先包一层红色,再在红色内部加 16dp 边距
  • 同一个 Modifier 只能使用一次(如不能写两个 .padding()),但可以通过链式组合其他 Modifier

本文深入剖析 Compose 的四个核心概念,帮你从「会用」进阶到「理解」。配合 Compose 从 0 到 1 阅读效果最佳。

@Composable —— UI 就是函数

作用

@Composable 是 Compose 的灵魂注解。被它标记的函数就是一个 UI 组件,可以直接在别的函数里调用。

核心规则

规则说明
函数名首字母大写约定俗成,与普通函数区分(如 MyButton 而非 myButton
只能被 @Composable 函数调用普通函数不能直接调用 Composable 函数
没有返回值Composable 函数通常返回 Unit(描述 UI,不返回对象)
可接收任意参数通过参数控制 UI 外观和行为,实现复用
默认没有顺序调用顺序不代表实际排列顺序(需要在布局容器中才有顺序)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义一个 Composable 控件
@Composable
fun MyTitle() {
Text(text = "我是标题")
}

// 在别的地方直接调用函数名就能用
@Composable
fun MyPage() {
MyTitle()
MyTitle()
MyTitle() // 调三次就显示三个标题
}

带参数的可复用组件

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
@Composable
fun GreetingCard(name: String, isVip: Boolean = false) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(
if (isVip) Color(0xFFFFD700) else Color.White,
RoundedCornerShape(8.dp)
)
.padding(12.dp)
) {
Text(
text = "欢迎,$name",
fontWeight = if (isVip) FontWeight.Bold else FontWeight.Normal
)
if (isVip) {
Text("👑", modifier = Modifier.padding(start = 8.dp))
}
}
}

// 使用
@Composable
fun UserList() {
Column {
GreetingCard("张三")
GreetingCard("李四", isVip = true)
GreetingCard("王五")
}
}

适用场景

  • 一切 UI 相关的函数:控件、布局、页面、弹窗、标题栏等

⚠️ 注意

  • Composable 函数可能被频繁调用(重组时),不要在内部做耗时操作
  • 副作用(网络请求、数据库读写)应使用 LaunchedEffect / SideEffect
  • 函数内部创建的局部变量,每次重组都会重新创建
  • 命名规范:Composable 函数首字母大写,参数顺序按重要性排列

Modifier —— 所有外观全靠它

作用

Modifier 是 Compose 的外观装饰系统,控制控件的尺寸、内边距、背景、圆角、边框、点击、滚动等一切外观和行为。所有控件都可以通过 modifier 参数接收 Modifier。

核心概念

链式调用

1
2
3
4
5
Modifier
.size(100.dp) // 最外层:固定 100x100
.background(Blue) // 第二层:蓝色背景
.padding(16.dp) // 第三层:内边距 16dp
.clip(CircleShape) // 最内层:圆形裁剪

Modifier 是从外到内依次包裹的。上面这段代码的效果:最外限定 100x100 区域 → 铺蓝底 → 在蓝色内部再留 16dp 边距 → 最终裁剪成圆形。

顺序决定效果

1
2
3
4
5
6
7
8
9
// 先 background 再 padding:背景色填满,然后内容区域向内缩
Modifier
.background(Color.Red)
.padding(16.dp)

// 先 padding 再 background:内边距也被背景色覆盖
Modifier
.padding(16.dp)
.background(Color.Red)
顺序效果
.background(...).padding(...)背景在 padding 之外,背景区域更大
.padding(...).background(...)背景在 padding 之内,背景区域更小

常用 Modifier 分类速查

尺寸类

Modifier说明
size(width, height)固定宽高
width(dp) / height(dp)固定宽度 / 高度
fillMaxWidth() / fillMaxHeight()填满父容器宽度 / 高度
fillMaxSize()宽高都填满
wrapContentWidth() / wrapContentHeight()包裹内容宽度 / 高度
defaultMinSize(minWidth, minHeight)最小尺寸约束
sizeIn(minWidth, maxWidth, minHeight, maxHeight)尺寸范围约束

内边距类

Modifier说明
padding(all)四边相同
padding(horizontal, vertical)水平和垂直
padding(start, top, end, bottom)分别设置

外观类

Modifier说明
background(color)纯色背景
background(color, shape)带形状的背景
clip(shape)裁剪形状
border(width, color)边框
border(width, color, shape)带形状的边框
shadow(elevation, shape)阴影
alpha(fraction)透明度
rotate(degrees)旋转
scale(scaleX, scaleY)缩放
offset(x, y)偏移(不影响布局)

交互类

Modifier说明
clickable { }点击
combinedClickable(onClick, onLongClick)点击 + 长按

布局类

Modifier说明
weight(fraction)权重分配(Row / Column 内)
align(alignment)对齐(Box 内)

综合示例

1
2
3
4
5
6
7
8
9
10
11
Box(
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Blue)
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
.clickable { },
contentAlignment = Alignment.Center
) {
Text("点我", color = Color.White)
}

适用场景

  • 几乎所有控件的外观控制:设置大小、加边距、上颜色、加边框、添加交互

⚠️ 注意

  • 顺序很重要:从外到内依次应用,不同顺序效果不同
  • 同一个 Modifier 只能使用一次:不能写两个 .padding(),需要用参数组合
  • 自定义 Modifier:通过扩展函数封装复用逻辑
  • 可组合Modifier 是不可变的,每个 .xxx() 返回一个新的 Modifier 对象

状态(State)—— 数据变了,界面自动刷新

作用

State 是 Compose 最核心的心智模型:你只管改数据,UI 自动刷新。不需要 findViewByIdsetTextnotifyDataSetChanged

核心 API

mutableStateOf —— 创建可观察变量

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }

Column {
Text(text = "当前计数:$count")
Button(onClick = { count++ }) {
Text("点我 +1")
}
}
}
关键字作用
mutableStateOf(0)创建一个”可观察”的变量,值变了会自动通知界面刷新
remember { }让变量在界面重组时”记住”当前值,不会被重置
byKotlin 委托语法,让你直接写 count++ 而不是 count.value++

mutableStateOf 的三种写法

1
2
3
4
5
6
7
8
9
10
11
// 方式 1:委托(推荐,最简洁)
var count by remember { mutableStateOf(0) }
count++ // 直接用

// 方式 2:直接使用 .value
val count = remember { mutableStateOf(0) }
count.value++ // 通过 .value 访问

// 方式 3:解构声明
val (value, setValue) = remember { mutableStateOf(0) }
setValue(value + 1)

常见状态类型

状态写法示例
基本类型mutableStateOf(0)计数器、开关状态
字符串mutableStateOf("")输入框内容
布尔值mutableStateOf(false)展开/折叠、选中状态
列表mutableStateListOf<T>()待办列表、聊天记录
集合mutableStateMapOf<K, V>()键值对状态
对象mutableStateOf(MyData())表单数据
可空类型mutableStateOf<T?>(null)可选数据

列表状态示例

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
@Composable
fun TodoList() {
val todoList = remember { mutableStateListOf<String>() }
var inputText by remember { mutableStateOf("") }

Column {
Row {
TextField(
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f)
)
Button(onClick = {
if (inputText.isNotBlank()) {
todoList.add(inputText)
inputText = ""
}
}) {
Text("添加")
}
}
LazyColumn {
items(todoList.size) { index ->
Text(
text = "${index + 1}. ${todoList[index]}",
modifier = Modifier.padding(8.dp)
)
}
// 列表变化 → 自动刷新!
}
}
}

remember 的进阶用法

remember(key) —— 依赖 key 变化重新计算

1
2
3
4
5
6
7
8
@Composable
fun UserProfile(userId: String) {
// 当 userId 变化时,重新加载用户数据
val userData = remember(userId) {
loadUserData(userId) // 仅 userId 变化时执行
}
Text("用户名:${userData.name}")
}

rememberSaveable —— 屏幕旋转后保留

1
2
3
4
5
// remember:屏幕旋转后丢失
var temp by remember { mutableStateOf("") }

// rememberSaveable:屏幕旋转后保留(自动存到 Bundle)
var saved by rememberSaveable { mutableStateOf("") }

remember 生命周期

1
2
3
4
5
6
7
8
9
10
首次进入 Composable
remember {} 执行,创建状态

用户交互,状态变化
→ 触发重组(Recomposition)
remember 返回已有值,不重新创建

从界面移除(不再显示)
→ 状态被销毁
→ 下次显示时重新创建

适用场景

  • 任何需要「数据变化 → UI 自动更新」的场景:表单、列表、动画、主题切换等

⚠️ 注意

  • 必须用 remember 包裹var count = mutableStateOf(0)(缺少 remember)每次重组都会重置
  • 状态提升(State Hoisting):将状态提升到父组件,通过参数传递,实现单向数据流
  • mutableStateListOfmutableStateMapOf 自带可观察能力,内部元素变化会自动刷新
  • 避免过度使用:不是所有变量都需要 state,只有 UI 相关的才需要

重组(Recomposition)—— 谁变了就刷新谁

作用

当状态(State)变化时,Compose 会智能地只重绘受影响的控件,没有变化的部分原地不动。这是 Compose 高性能的核心机制。

工作原理

1
2
3
4
5
6
7
8
9
State 变化

Compose 标记「受影响的 Composable 函数」

执行 Recomposition:重新调用被标记的函数

对比新旧 UI,仅更新变化的部分

不受影响的函数 → 直接跳过,零开销

直观理解

1
2
3
count 变了 → 只有 Text("当前计数:$count") 会重新渲染
→ Button 不动
→ 页面上 100 个其他控件也不动

重组的特点

特性说明
选择性只重组读取了变化状态的函数
乐观任何状态变化都可能触发重组,但 Compose 会跳过无变化的部分
可中断新的状态变化可以打断正在进行的重组
并行多个 Composable 可以并行执行重组
幂等同一输入多次重组结果相同(不应该有副作用)

避免不必要的重组

使用不可变数据

1
2
3
4
5
// ❌ 不稳定,每次重组都算新对象
data class User(var name: String, var age: Int)

// ✅ 稳定,值不变对象就不变
data class User(val name: String, val age: Int)

使用 derivedStateOf 派生状态

1
2
3
4
5
6
7
8
9
10
11
12
@Composable
fun FilteredList(items: List<String>, query: String) {
// ❌ 每次重组都重新过滤(即使 query 没变)
val filtered = items.filter { it.contains(query) }

// ✅ 仅在 items 或 query 变化时重新过滤
val filtered by remember { derivedStateOf { items.filter { it.contains(query) } } }

LazyColumn {
items(filtered) { item -> Text(item) }
}
}

3. 状态读取延迟

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun HeavyItem(onClick: () -> Unit, isSelected: Boolean) {
// ⚠️ isSelected 变化 → 整个 HeavyItem 重组
// 包括 1000 行的复杂 UI
Box(modifier = Modifier.clickable(onClick = onClick)) {
// ... 1000 行复杂 UI
if (isSelected) {
Border()
}
}
}
1
2
3
4
5
6
7
8
@Composable
fun HeavyItem(onClick: () -> Unit, isSelected: Boolean) {
// ✅ 把 isSelected 读解放到单独的 Composable 中
Box(modifier = Modifier.clickable(onClick = onClick)) {
// ... 1000 行复杂 UI(不重绘)
SelectionBorder(isSelected) // 仅这个函数被重绘
}
}

SideEffect —— 在重组中安全执行副作用

1
2
3
4
5
6
7
8
9
10
@Composable
fun AnalyticsTracker(pageName: String) {
// ❌ 不要在 Composable 直接做副作用(会在每次重组时执行)
// FirebaseAnalytics.logEvent("page_view", ...)

// ✅ 使用 LaunchedEffect,仅 pageName 变化时执行
LaunchedEffect(pageName) {
logPageView(pageName)
}
}
副作用 API使用场景
LaunchedEffect(key)协程操作,key 变化时重新启动
SideEffect每次重组后执行(同步)
DisposableEffect(key)需要清理的副作用(如注册/注销监听)
rememberCoroutineScope()获取 Composable 生命周期内的协程作用域

适用场景

  • 理解 Compose 性能、排查 UI 不刷新 bug、优化复杂列表

⚠️ 注意

  • Composable 函数应该幂等:同样的输入,无论调用多少次结果相同
  • 不要在 Composable 中直接做副作用:使用 Effect API
  • 不要依赖 Composable 函数的调用顺序和次数:Compose 可能会跳过、重复调用
  • 不要读取或修改全局变量:使用 State 代替
  • 使用 Layout Inspector 或 Compose 编译器报告的「restartable」「skippable」标记来排查性能问题

告别 XML 写布局的年代!Compose 是 Android 未来的 UI 开发方式。这篇指南用最直白的方式,带你从安装到写出第一个完整页面。

什么是 Jetpack Compose?

一句话

用 Kotlin 代码直接写 UI,不再需要 XML。

过去写 Android 页面要两个文件配合:activity_main.xml(画界面)+ MainActivity.kt(写逻辑)。Compose 把两者合二为一,全部用 Kotlin 搞定。

为什么学 Compose?

对比维度传统 XMLCompose
语言XML + Kotlin/Java纯 Kotlin
文件数1 个页面 ≥ 2 个文件1 个文件搞定
UI 更新findViewById + 手动设值自动重组(数据变了界面自动刷新)
学习曲线平缓但写起来累稍陡但写起来爽
嵌套性能嵌套越深越卡无嵌套问题
Google 态度维护但不再主推⭐ 官方主推,全力投入

结论:现在开始学 Android,直接从 Compose 起步,不需要学 XML。

环境搭建

下载安装

  1. Android Studio 下载页 下载最新版(Hedgehog 以上)
  2. 一路 Next 安装,SDK 按默认勾选即可
  3. 首次启动可能会下载 SDK,等它完成

创建第一个 Compose 项目

  1. 打开 Android Studio → New Project
  2. 选择 「Empty Activity」(不是 Empty Views Activity!)
  3. 填写:
    • Name:MyFirstApp
    • Package name:com.example.myfirstapp
    • Language:Kotlin
    • Minimum SDK:API 24(Android 7.0)
  4. 点击 Finish,等待 Gradle 同步完成

⚠️ 关键区分Empty Activity 是 Compose 项目,Empty Views Activity 是传统 XML 项目。选错了你就回到旧时代了。

项目长什么样?

创建完成后,你看到的 MainActivity.kt 大概是这样的:

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
package com.example.myfirstapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.myfirstapp.ui.theme.MyFirstAppTheme

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyFirstAppTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Greeting("Android")
}
}
}
}
}

@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
MyFirstAppTheme {
Greeting("Android")
}
}

各部分解释

代码作用
setContent { }Compose 的入口,替代了 setContentView(R.layout.xxx)
@Composable标记函数是一个 UI 组件(这是 Compose 的核心注解)
@Preview让函数可以在 Android Studio 右侧预览,不需要跑模拟器
MaterialTheme应用 Material Design 3 主题

纯 Kotlin写UI和 XML 的对应关系

XML 方式Compose (Kotlin)
FrameLayoutBox
LinearLayout(vertical)Column
LinearLayout(horizontal)Row
RecyclerViewLazyColumn / LazyRow
android:layout_width="match_parent"Modifier.fillMaxWidth()
android:layout_height="match_parent"Modifier.fillMaxHeight() / fillMaxSize()
android:layout_margin / paddingModifier.padding()
android:backgroundModifier.background()
android:gravityModifier.align() / Arrangement

Compose 页面生命周期完全指南

Compose 的生命周期不是单一概念,而是三层叠加模型

  1. Android 生命周期LifecycleOwner)—— Activity / Fragment 的传统生命周期
  2. 组合生命周期(Composition)—— Composable 进入/离开组合树
  3. Effect 生命周期LaunchedEffect / DisposableEffect)—— 副作用随 key 变化

理解这三层如何协作,是写出无内存泄漏、无状态错乱的 Compose 代码的关键。


一、三层生命周期全景图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
     onCreate()     →  Composition    →  Composition
onStart() 首次进入 重组
onResume()
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────┐
│ Activity Lifecycle │
│ Lifecycle.Event.ON_START / ON_RESUME / ... │
└─────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────┐
│ Composition(组合) │
│ 进入组合 → 重组(recomposition) → 退出组合 │
└─────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────┐
│ Effect(副作用) │
│ LaunchedEffect → key 变化 → 协程取消 │
│ DisposableEffect → key 变化 → dispose → 重启 │
└─────────────────────────────────────────────────┘

关键区别

层次粒度触发时机主要用途
Android Lifecycle页面级onStart / onResume / onStop整体可见性、前后台切换
Composition组件级首次组合 / 重组 / 退出UI 渲染、remember
Effect副作用key 变化 / 退出组合协程、监听器、资源管理

二、Composition 生命周期 —— 进入与退出

2.1 进入组合(Enter Composition)

Composable 函数首次被调用并加入组合树

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun MyScreen() {
// 每次重组都会执行这里
val name = remember { "World" } // 只在首次组合时计算

// 首次进入组合时执行
DisposableEffect(Unit) {
println("📌 Entered composition")
onDispose { println("📌 Left composition") }
}

Text("Hello $name")
}

2.2 重组(Recomposition)

State 发生变化,依赖该 State 的 Composable 会被重新执行

1
2
3
4
5
6
7
8
9
10
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
// ↑ count 变化会触发重组

Text("Count: $count") // 重新执行
Button(onClick = { count++ }) { // onClick lambda 不变
Text("Increment")
}
}

2.3 退出组合(Leave Composition)

Composable 从组合树中移除(条件渲染、导航离开等):

1
2
3
4
5
6
@Composable
fun Parent(showChild: Boolean) {
if (showChild) {
Child() // showChild = false 时退出组合
}
}

三、Effect API —— 副作用四大件

Compose 提供 4 个 Effect API 来管理副作用。它们的核心区别在于何时执行何时清理

3.1 LaunchedEffect —— 协程的入口

key 变化 → 取消旧协程 → 启动新协程;退出组合 → 取消协程

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun LoadUserData(userId: String) {
var user by remember { mutableStateOf<User?>(null) }

LaunchedEffect(userId) {
// userId 变化时:取消旧协程 → 启动新协程
user = api.fetchUser(userId)
}

user?.let { Text(it.name) } ?: CircularProgressIndicator()
}

LaunchedEffect 生命周期时序

1
2
3
4
5
6
7
8
userId="a"          userId="b"        退出组合
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│协程: a │ → │取消 a │ → │取消 b
│fetch... │ │协程: b │ │ │
│ │ │fetch... │ │ │
└─────────┘ └─────────┘ └─────────┘

3.2 DisposableEffect —— 需要清理的副作用

**key 变化 → dispose() → 重新 enter();退出组合 → dispose()**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Composable
fun ObserveUserStatus(userId: String) {
DisposableEffect(userId) {
val observer = object : Observer {
override fun onChange(status: String) {
// 更新状态
}
}
api.registerObserver(userId, observer)

onDispose {
api.unregisterObserver(userId, observer) // ← 清理!
}
}

// 这里不需要 LaunchedEffect,一次性注册 + 清理即可
}

DisposableEffect 典型场景

场景注册onDispose
Lifecycle 观察lifecycle.addObserver(...)lifecycle.removeObserver(...)
广播接收器context.registerReceiver(...)context.unregisterReceiver(...)
传感器监听sensorManager.registerListener(...)sensorManager.unregisterListener(...)
EventBusEventBus.register(...)EventBus.unregister(...)
WebSocketws.connect()ws.close()

3.3 SideEffect —— 每次重组后执行(无 key)

每次成功重组后调用,无清理逻辑。适用于与非 Compose 状态同步:

1
2
3
4
5
6
7
8
9
@Composable
fun AnalyticsScreen(screenName: String) {
val analytics = LocalAnalytics.current

SideEffect {
analytics.logScreenView(screenName)
// 每次重组后都执行(无 key,无法跳过)
}
}

3.4 rememberUpdatedState —— 保持引用最新

解决闭包捕获旧值的问题。长生命周期的 Effect 需要获取最新状态,但又不希望 key 变化导致重启:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun LandingPage(onTimeout: () -> Unit) {
// ❌ 错误:onTimeout 变化会导致 LaunchedEffect 重启
LaunchedEffect(Unit) {
delay(3000L)
onTimeout() // 可能拿到旧的回调
}

// ✅ 正确:referenceToTimeout 总是最新的,但不会重启协程
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(3000L)
currentOnTimeout() // 始终拿到最新的 onTimeout
}
}

3.5 Effect 对比总结

APIkey 变化退出组合每次重组后有清理可挂起
LaunchedEffect取消 + 重启取消自动取消协程
DisposableEffectdispose + 重启dispose手动 onDispose
SideEffect
rememberUpdatedState

四、Android 生命周期集成

4.1 获取 LifecycleOwner

1
2
3
// Compose 中获取 Lifecycle
val lifecycleOwner = LocalLifecycleOwner.current
val lifecycle = lifecycleOwner.lifecycle

LocalLifecycleOwner 由 Navigation 或包含 ComposeView 的 Activity/Fragment 自动提供。

4.2 监听生命周期事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Composable
fun LifecycleAwareComponent() {
val lifecycleOwner = LocalLifecycleOwner.current

DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> println("👀 页面可见")
Lifecycle.Event.ON_STOP -> println("🙈 页面不可见")
Lifecycle.Event.ON_RESUME -> println("👉 获得焦点")
Lifecycle.Event.ON_PAUSE -> println("👈 失去焦点")
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)

onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}

4.3 常用生命周期事件含义

1
2
3
4
5
6
7
8
9
10
11
 ON_CREATE ═══ ON_START ═══ ON_RESUME
│ │ │
│ │ [页面在前台,可交互]
[页面可见]
[初始化完成] │ ON_PAUSE
│ │
▼ ▼
ON_STOP ← [部分不可见]


ON_DESTROY

4.4 组合 Lifecycle 与 StateFlow 的协程

**核心 API:repeatOnLifecycle**。在正确的生命周期状态下收集 flow,自动暂停/恢复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun ObserveWithLifecycle(viewModel: MyViewModel) {
val lifecycleOwner = LocalLifecycleOwner.current

val uiState by produceState<UiState>(
initialValue = UiState.Loading,
key1 = lifecycleOwner,
key2 = viewModel,
) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { value = it }
}
}
}

更简洁的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ViewModel 中
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}

// Composable 中 —— 使用 flowWithLifecycle 扩展
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val lifecycleOwner = LocalLifecycleOwner.current
val uiState by viewModel.uiState
.flowWithLifecycle(
lifecycle = lifecycleOwner.lifecycle,
minActiveState = Lifecycle.State.STARTED
)
.collectAsStateWithLifecycle(initialValue = UiState.Loading)
}

collectAsStateWithLifecycle(推荐)

1
2
3
4
5
6
7
8
// lifecycle-runtime-compose 库
// implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")

@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 自动在 STARTED 收集,在 STOPPED 取消,无需手动 repeatOnLifecycle
}

4.5 生命周期状态速查

状态何时进入适合做的事情
CREATEDActivity 创建后一次性初始化
STARTED页面可见开始收集 Flow、动画、网络请求
RESUMED页面在前台可交互相机、位置更新、高优先级任务
PAUSED部分可见(如弹窗遮挡)暂停相机、降低刷新频率
STOPPED完全不可见停止 Flow 收集、释放重资源
DESTROYED销毁前最终清理

五、remember 与 rememberSaveable

5.1 remember —— 跨重组保留

1
2
3
4
5
6
7
8
9
@Composable
fun RememberExample() {
// ✅ 重组后保留
var count by remember { mutableIntStateOf(0) }

// ❌ 每次重组重置为 0
var badCount by mutableIntStateOf(0)
// ———— 因为重组会重新执行整个函数!
}

5.2 rememberSaveable —— 跨进程死亡保留

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun SearchScreen() {
var query by rememberSaveable { mutableStateOf("") }
// 进程被杀死后恢复,query 值仍然保留

OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search") }
)
}
方式跨重组跨配置变更跨进程死亡
remember
rememberSaveable
ViewModel否(需 SavedStateHandle
ViewModel + SavedStateHandle

5.3 自定义 Saveable Saver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
data class User(val name: String, val age: Int)

val UserSaver = run {
val nameKey = "name"
val ageKey = "age"
mapSaver(
save = { mapOf(nameKey to it.name, ageKey to it.age.toString()) },
restore = { User(it[nameKey]!!, it[ageKey]!!.toInt()) }
)
}

@Composable
fun UserScreen() {
var user by rememberSaveable(stateSaver = UserSaver) {
mutableStateOf(User("Alice", 25))
}
}

六、ViewModel 生命周期

6.1 ViewModel 何时销毁

1
2
3
4
5
6
7
8
9
10
Activity/Fragment 创建


ViewModel 创建

... 配置变更(旋转屏幕)... ViewModel 不销毁!


onCleared()
(Activity 真正销毁 / Fragment 移除)

6.2 ViewModel + SavedStateHandle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class EditorViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {

var title: String
get() = savedStateHandle.get<String>("title") ?: ""
set(value) { savedStateHandle["title"] = value }

var content: String
get() = savedStateHandle.get<String>("content") ?: ""
set(value) { savedStateHandle["content"] = value }
}

@Composable
fun EditorScreen(viewModel: EditorViewModel = viewModel()) {
// 进程杀死后恢复时,title 和 content 自动还原
OutlinedTextField(
value = viewModel.title,
onValueChange = { viewModel.title = it },
)
}

6.3 ViewModel 初始化时机(懒加载)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sealed interface EditorUiState {
data object Loading : EditorUiState
data class Ready(val title: String, val content: String) : EditorUiState
}

class EditorViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {

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

init {
// ViewModel 创建时加载数据(懒加载:首次访问 ViewModel 时)
loadContent()
}

private fun loadContent() {
val title = savedStateHandle.get<String>("title") ?: ""
val content = savedStateHandle.get<String>("content") ?: ""
_uiState.value = EditorUiState.Ready(title, content)
}
}

七、CompositionLocal —— 作用域内生命周期

CompositionLocal 的值在组合树的某个节点提供,子节点退出组合时自动不可见:

1
2
3
4
5
6
7
8
9
val LocalThemeColor = compositionLocalOf { Color.Unspecified }

@Composable
fun ThemeProvider(color: Color, content: @Composable () -> Unit) {
CompositionLocalProvider(LocalThemeColor provides color) {
content() // 这里及子节点可获取 LocalThemeColor
}
// 离开这个 scope,回到默认值
}

八、配置变更 —— 屏幕旋转、语言切换等

资源配置变更时行为进程死亡时行为
remember丢失丢失
rememberSaveable保留保留
ViewModel保留丢失
ViewModel + SavedStateHandle保留保留
object / companion 单例保留丢失
DataStore / SharedPreferences保留保留

最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun OrderScreen(orderId: String) {
// 短期 UI 状态 → rememberSaveable
var expanded by rememberSaveable { mutableStateOf(false) }

// 业务数据 → ViewModel + SavedStateHandle
val viewModel: OrderViewModel = viewModel()
val order by viewModel.order.collectAsStateWithLifecycle()

LaunchedEffect(orderId) {
viewModel.loadOrder(orderId)
}
}

九、页面生命周期实战场景

9.1 回到前台时刷新数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun RefreshOnResume(viewModel: DataViewModel) {
val lifecycle = LocalLifecycleOwner.current.lifecycle

DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
}

9.2 离开页面时暂停视频

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Composable
fun VideoPlayer(videoId: String) {
val lifecycle = LocalLifecycleOwner.current.lifecycle

DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> player.pause()
Lifecycle.Event.ON_RESUME -> player.resume()
Lifecycle.Event.ON_STOP -> player.release()
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
player.release()
}
}
}

9.3 秒杀倒计时(退出页面自动取消)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun FlashSaleCountdown(endTime: Long) {
var remaining by remember { mutableLongStateOf(endTime - System.currentTimeMillis()) }

LaunchedEffect(Unit) {
while (remaining > 0) {
delay(1000)
remaining = (endTime - System.currentTimeMillis()).coerceAtLeast(0)
}
}
// ✅ 退出页面时协程自动取消,无需手动清理

Text("${remaining / 1000} 秒后结束")
}

9.4 权限请求(关联生命周期)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Composable
fun RequestCameraPermission(onGranted: () -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) onGranted()
}

DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
launcher.launch(android.Manifest.permission.CAMERA)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}

9.5 定时器 + 可见性联动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun AutoRefreshFeed(viewModel: FeedViewModel) {
val lifecycle = LocalLifecycleOwner.current.lifecycle

// lifecycle 作为 key:在 STARTED 时启动协程,STOPPED 时自动取消
LaunchedEffect(lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) {
delay(30_000)
viewModel.refresh()
}
}
}
}

十、常见生命周期错误与修复

问题原因修复
协程泄漏LaunchedEffect key 用 Unit 但外部依赖变化key 传入依赖项(如 userId
Flow 在后台仍然收集直接用 collectAsState()改用 collectAsStateWithLifecycle()repeatOnLifecycle
配置变更后状态丢失用了 remember 存重要状态改用 rememberSaveableSavedStateHandle
ViewModel 重新创建在 Composable 函数内手动 ViewModel() 且传了错误参数确保 key 参数正确;优先使用 viewModel() 工厂
动画在 ON_STOP 时继续动画未绑定生命周期LaunchedEffect + repeatOnLifecycle 包裹动画循环
onDispose 不执行DisposableEffect key 用了可变对象key 使用 stable 类型(StringIntUnit 等)
闭包捕获过期值Effect 启动后 lambda 被替换rememberUpdatedState 包装回调

十一、生命周期相关依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
dependencies {
// Compose 基础(含 remember、Effect API)
implementation("androidx.compose.runtime:runtime")

// ViewModel 集成
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

// collectAsStateWithLifecycle(一键生命周期安全收集)
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")

// SavedStateHandle
implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7")
}

十二、生命周期决策速查表

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
需要做一件事,放在哪里?

┌─ 是否需要在「每次重组」后执行?
│ ├─ 是 → SideEffect { }
│ └─ 否 ↓

├─ 是否需要在「离开组合 / key 变化」时清理?
│ ├─ 是 → DisposableEffect(key) { onDispose { } }
│ └─ 否 ↓

├─ 是否需要「协程 / delay / 异步」?
│ ├─ 是 → LaunchedEffect(key) { }
│ └─ 否 ↓

├─ 是否需要「依赖 Android 生命周期」?
│ ├─ 是 → DisposableEffect(lifecycle) + repeatOnLifecycle
│ └─ 否 ↓

├─ 是否「UI 状态」需要在配置变更后恢复?
│ ├─ 是 → rememberSaveable { }
│ └─ 否 → remember { }

└─ 是否「业务数据」需要在进程死亡后恢复?
├─ 是 → ViewModel + SavedStateHandle
└─ 否 → ViewModel

全文覆盖 Compose 生命周期三层模型(Android Lifecycle / Composition / Effect)、四大 Effect API、rememberSaveablerepeatOnLifecycle、5 个实战场景和 7 类常见错误修复方案。

本文深入讲解 Compose Navigation 的全部用法,从基础跳转到深层链接、BottomNavigation 联动、动画转场。配合 Compose 从 0 到 1 阅读效果最佳。

Navigation 三大核心组件

Compose Navigation 由三个核心组件构成:

组件作用创建方式
NavController管理返回栈,控制跳转rememberNavController()
NavHost路由表,把路由和界面绑定NavHost(navController, startDestination) { }
composable()注册一个路由对应的页面composable("route") { Screen() }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
fun AppNavigation() {
val navController = rememberNavController() // ① 创建 NavController

NavHost(
navController = navController,
startDestination = "home" // ② 默认首页
) {
composable("home") { // ③ 路由"home" → HomeScreen
HomeScreen(navController)
}
composable("detail") {
DetailScreen(navController)
}
}
}

⚠️ rememberNavController() 必须在 NavHost 外部创建,如果在 NavHost 内部创建会导致每次重组都重新生成。

路由定义

字符串路由(基础)

1
2
3
4
5
6
// 方式一:直接写字符串(适合简单场景)
composable("home") { HomeScreen() }
composable("profile") { ProfileScreen() }

// 跳转
navController.navigate("profile")

常量管理(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
object Routes {
const val HOME = "home"
const val DETAIL = "detail/{itemId}"
const val SETTINGS = "settings"
const val PROFILE = "profile/{userId}"

// 辅助函数:生成带参数的路由
fun detail(itemId: String) = "detail/$itemId"
fun profile(userId: Int) = "profile/$userId"
}

// 使用
navController.navigate(Routes.detail("abc123"))

✅ 集中管理路由常量,方便跳转和避免拼写错误。

路由参数速查表

参数写法含义示例路由
{arg}必选参数detail/{id}detail/123
{arg}={default}带默认值list/{page}=1
{arg}?argType=Int指定类型user/{id}arguments.add(NavType)
?arg={arg}可选查询参数search?q={query}

参数传递

必选参数(路径参数)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 路由定义
composable(
route = "detail/{itemId}",
arguments = listOf(
navArgument("itemId") { type = NavType.StringType }
)
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId")
DetailScreen(itemId = itemId ?: "")
}

// 跳转
navController.navigate("detail/abc123")

可选参数(查询参数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 路由定义
composable(
route = "profile?userId={userId}&showEdit=false",
arguments = listOf(
navArgument("userId") {
type = NavType.IntType
defaultValue = -1 // ← 默认值
},
navArgument("showEdit") {
type = NavType.BoolType
defaultValue = false
}
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getInt("userId") ?: -1
val showEdit = backStackEntry.arguments?.getBoolean("showEdit") ?: false
ProfileScreen(userId, showEdit)
}

// 跳转(两种方式都可以)
navController.navigate("profile?userId=42")
navController.navigate("profile?userId=42&showEdit=true") // 全部参数

🔑 可选参数 = ? + = + defaultValue,三者缺一不可。

参数类型对照表

NavTypeKotlin 类型取值方法
StringTypeStringgetString("key")
IntTypeIntgetInt("key")
LongTypeLonggetLong("key")
FloatTypeFloatgetFloat("key")
BoolTypeBooleangetBoolean("key")
StringArrayTypeArray<String>getStringArray("key")
IntArrayTypeIntArraygetIntArray("key")

导航操作 —— 跳转、返回、清栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 基础跳转
navController.navigate("detail")

// navigate 常用选项
navController.navigate("detail") {
// 1. 启动模式:单例(返回栈已存在则复用)
launchSingleTop = true

// 2. 弹出页面(跳转前把当前页移出返回栈)
popUpTo(Routes.HOME) { inclusive = true } // 回退到 HOME,并移除 HOME 本身

// 3. 恢复之前的状态
restoreState = true
}

popBackStack() 返回

1
2
3
4
5
6
7
8
// 返回到上一个页面
navController.popBackStack()

// 返回到指定路由(不包含该路由本身)
navController.popBackStack("home", inclusive = false)

// 返回到指定路由(并移除该路由本身)
navController.popBackStack("home", inclusive = true)
1
2
3
4
5
// 等同于物理返回键
navController.navigateUp()

// 如果返回栈已是根节点,回到上一个 Activity
// 等同于 navController.popBackStack()

导航选项组合实战

1
2
3
4
5
// 场景:登录成功后跳转主页,且按返回键不再回到登录页
navController.navigate("home") {
popUpTo("login") { inclusive = true } // 把登录页也清掉
launchSingleTop = true // 防止重复创建主页
}

返回栈管理

返回栈原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
NavHost 启动 startDestination="home"
┌─────────┐
│ home │ ← 根页面
└─────────┘

navigate("detail")
┌─────────┐
│ detail │ ← 推到栈顶
├─────────┤
│ home │
└─────────┘

popBackStack()
┌─────────┐
│ home │ ← 栈顶回到这里
└─────────┘

获取当前路由

1
2
3
4
5
6
7
8
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route

// 典型用途:高亮当前 tab
BottomNavigationItem(
selected = currentRoute == "home",
...
)

监听导航事件

1
2
3
4
5
6
7
// 监听路由变化(例如埋点统计)
val currentEntry by navController.currentBackStackEntryAsState()
LaunchedEffect(currentEntry) {
currentEntry?.destination?.route?.let { route ->
Log.d("Navigation", "当前页面:$route")
}
}

Deep Link —— 从外部打开指定页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
composable(
route = "detail/{itemId}",
arguments = listOf(
navArgument("itemId") { type = NavType.StringType }
),
deepLinks = listOf(
navDeepLink { uriPattern = "myapp://detail/{itemId}" },
navDeepLink {
uriPattern = "https://example.com/detail/{itemId}"
action = Intent.ACTION_VIEW
}
)
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId") ?: ""
DetailScreen(itemId)
}

AndroidManifest.xml 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<activity
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="detail" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="example.com"
android:pathPrefix="/detail" />
</intent-filter>
</activity>

BottomNavigation + Navigation 联动

标准实现

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
data class BottomNavItem(
val label: String,
val icon: ImageVector,
val route: String
)

@Composable
fun MainScreen() {
val navController = rememberNavController()

val items = listOf(
BottomNavItem("首页", Icons.Default.Home, "home"),
BottomNavItem("搜索", Icons.Default.Search, "search"),
BottomNavItem("消息", Icons.Default.Notifications, "message"),
BottomNavItem("我的", Icons.Default.Person, "profile"),
)

Scaffold(
bottomBar = {
NavigationBar {
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route

items.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
// 防止多个栈的同一个页面重叠
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(innerPadding)
) {
composable("home") { HomeScreen() }
composable("search") { SearchScreen() }
composable("message") { MessageScreen() }
composable("profile") { ProfileScreen() }
}
}
}

⚠️ 关键popUpTo(findStartDestination) + saveState + restoreState 这个三件套可防止切换 tab 时重复创建页面并保持滚动位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Scaffold(
// 侧边护栏(宽度 > 600dp 时自动推荐)
// 用法和 NavigationBar 完全一样
) { innerPadding ->
NavigationRail {
items.forEach { item ->
NavigationRailItem(
icon = { Icon(item.icon, null) },
label = { Text(item.label) },
selected = currentRoute == item.route,
onClick = { ... },
alwaysShowLabel = false
)
}
}
}

动画转场

composable 动画参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
composable(
route = "detail/{id}",
enterTransition = {
slideInHorizontally { it } + fadeIn() // 从右侧滑入 + 淡入
},
exitTransition = {
slideOutHorizontally { -it } + fadeOut() // 向左滑出 + 淡出
},
popEnterTransition = {
slideInHorizontally { -it } + fadeIn() // 返回时从左侧滑入
},
popExitTransition = {
slideOutHorizontally { it } + fadeOut() // 返回时向右滑出
}
) {
DetailScreen(it)
}

常用动画速查表

动画方法效果
slideInHorizontally { it }从右侧滑入
slideInHorizontally { -it }从左侧滑入
slideInVertically { it }从下方滑入
slideInVertically { -it }从上方滑入
fadeIn(initialAlpha = 0f)淡入
fadeOut(targetAlpha = 0f)淡出
scaleIn(initialScale = 0.8f)缩放进入
scaleOut(targetScale = 0.8f)缩放退出
slideInHorizontally { it } + fadeIn()组合动画(常见的推入+淡入)

🔧 it 代表完整宽度(水平)或完整高度(垂直),用 { it / 2 } 可控制滑动距离。

AnimatedNavHost 全局动画(Material 3)

1
2
3
4
5
6
7
8
9
10
11
// build.gradle.kts
implementation("androidx.compose.animation:animation:1.6.0")

@Composable
fun AppNavigation() {
val navController = rememberNavController()
AnimatedNavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
composable("detail") { DetailScreen(navController) }
}
}

嵌套导航图

当模块较多时,可以把路由分组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
NavHost(navController, startDestination = "main") {
// 嵌套导航图
navigation(
route = "auth",
startDestination = "login"
) {
composable("login") { LoginScreen() }
composable("register") { RegisterScreen() }
composable("forgotPassword") { ForgotPasswordScreen() }
}

// 主模块
navigation(
route = "main",
startDestination = "home"
) {
composable("home") { HomeScreen() }
composable("search") { SearchScreen() }
}

// 独立页面
composable("settings") { SettingsScreen() }
}

跳转到嵌套路由navController.navigate("auth/register")

常见坑与最佳实践

原因解决
快速双击跳转多个相同页面navigate 默认允许重复launchSingleTop = true
切换 tab 后页面状态丢失没有 saveState/restoreStateBottomNavigation 三件套
参数获取为空未声明 navArgument必须用 arguments = listOf(...) 声明
返回键直接退出应用返回栈为空判断 navController.previousBackStackEntry == null 时给提示
跳转后按返回又回到原始页没 popUpTo登录成功跳转后 popUpTo("login") { inclusive = true }
深层链接打不开AndroidManifest 没配 intent-filter两个 intent-filter 都要加

完整实战示例

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
object Routes {
const val HOME = "home"
const val DETAIL = "detail/{id}"
const val CART = "cart"
fun detail(id: String) = "detail/$id"
}

@Composable
fun ShopApp() {
val navController = rememberNavController()

NavHost(navController, startDestination = Routes.HOME) {
// 首页
composable(Routes.HOME) {
HomeScreen(
onItemClick = { id -> navController.navigate(Routes.detail(id)) },
onCartClick = { navController.navigate(Routes.CART) { launchSingleTop = true } }
)
}

// 详情页
composable(
route = Routes.DETAIL,
arguments = listOf(navArgument("id") { type = NavType.StringType }),
enterTransition = { slideInHorizontally { it } + fadeIn() },
exitTransition = { fadeOut() },
deepLinks = listOf(navDeepLink { uriPattern = "shop://detail/{id}" })
) { entry ->
DetailScreen(
id = entry.arguments?.getString("id") ?: "",
onBack = { navController.popBackStack() }
)
}

// 购物车
composable(Routes.CART) {
CartScreen(onBack = { navController.popBackStack() })
}
}
}

本文涵盖 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 实际编写示例来加深理解。动手敲一遍比看十遍管用!