一、Column从上到下
Column
是一个垂直线性布局组件,它能够将子项按照从上到下的顺序垂直排列。先看一下参数:
kotlin
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top, //垂直方向的排列
horizontalAlignment: Alignment.Horizontal = Alignment.Start, //水平方向的排列
content: @Composable ColumnScope.() -> Unit
) {...}
verticalArrangment
和horizontalAlignment
参数分别可以帮助我们安排子项的垂直/水平位置,在默认的情况下,子项会以垂直方向上靠上(Arrangment. Top)
,水平方向上靠左(Alignment. Start)
来布置。
下面是一个例子:
kotlin
@Composable
fun Greeting() {
Column(
modifier = Modifier
.border(1.dp, Color.Blue)
.size(300.dp),
verticalArrangement = Arrangement.Center, //垂直居中
horizontalAlignment = Alignment.CenterHorizontally //水平居中
) {
Text(text = "Hello,Compose", modifier = Modifier.align(Alignment.Start)) //水平居左
Text(text = "Hello,Android")
}
}
UI效果
对于垂直布局中的子项,Modifier.align
只能设置自己在水平方向的位置,反之水平布局的子项,只能设置自己在垂直方向的位置,并且Modifier.align
修饰符会优先于Column
的horizontalAlignment
参数。
Column
的参数verticalArrangement
有多个值,在Jetpack Compose 博物馆上有一张图描述的很清楚,如下:
二、Row从左到右
Row
组件能够将内部子项按照从左到右的方向水平排列。参数如下:
kotlin
@Composable
inline fun Row(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
content: @Composable RowScope.() -> Unit
) {...}
下面是一个例子:
kotlin
@Composable
fun Greeting() {
Row(
modifier = Modifier
.border(1.dp, Color.Blue)
.size(300.dp),
verticalAlignment = Alignment.CenterVertically, //整体垂直居中
horizontalArrangement = Arrangement.Center //整体水平居中
) {
Text(text = "Hello,Compose", modifier = Modifier.align(Alignment.Bottom)) //单独定义子项的位置
//增加竖直实线更好观察二个Text
Divider(color = Color.Blue, modifier = Modifier
.width(1.dp)
.fillMaxHeight())
Text(text = "Hello,Android")
}
}
UI效果
Row
的参数horizontalArrangement
有多个值,在Jetpack Compose 博物馆上有一张图描述的很清楚,如下:
三、Box重叠
Box
组件是一个能够将里面的子项依次按照顺序堆叠的布局组件,在使用上类似于传统视图中的FrameLayout
。参数如下:
kotlin
@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false, //是否传入的最小约束应该传递给content
content: @Composable BoxScope.() -> Unit
) {...}
在显示上,下面的组件盖在上面的组件上。Box
经常用来让文本在一定范围内居中:
kotlin
@Composable
fun Greeting() {
Box(
modifier = Modifier
.border(1.dp, Color.Blue)
.size(300.dp),
contentAlignment = Alignment.Center
) {
Text(text = "Hello,Compose")
}
}
UI效果
四、Spacer留白
在很多时候,需要让两个组件之间留有空白的间隔,这个时候就可以使用Spacer组件。参数如下:
kotlin
@Composable
@NonRestartableComposable
fun Spacer(modifier: Modifier) {
Layout({}, measurePolicy = SpacerMeasurePolicy, modifier = modifier)
}
如果想设置横向或者纵向的留白,只需要设置modifier
的宽高即可。下面是一个例子:
kotlin
Spacer(modifier = Modifier.height(30.dp))
五、ConstraintLayout约束布局
在构建嵌套层级复杂的视图界面时,使用约束布局可以有效降低视图树高度,使视图树扁平化。约束布局在测量布局耗时上,比传统的相对布局具有更好的性能表现,并且约束布局可以根据百分比自适应各种尺寸的终端设备。因为约束布局ConstraintLayout
十分好用,所以官方为我们迁移到了Compose
平台。
使用之前需要导包,如下:
kotlin
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1"
下面是ConstraintLayout
的参数:
kotlin
@Composable
inline fun ConstraintLayout(
modifier: Modifier = Modifier,
optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
crossinline content: @Composable ConstraintLayoutScope.() -> Unit
) {...}
或
kotlin
@OptIn(ExperimentalMotionApi::class)
@Suppress("NOTHING_TO_INLINE")
@Composable
inline fun ConstraintLayout(
constraintSet: ConstraintSet,
modifier: Modifier = Modifier,
optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
animateChanges: Boolean = false,
animationSpec: AnimationSpec<Float> = tween<Float>(),
noinline finishedAnimationListener: (() -> Unit)? = null,
noinline content: @Composable () -> Unit
) {...}
1、创建与绑定引用
在XML
文件中可以为View
组件设置资源ID
,并将资源ID
作为索引来声明组件应当摆放的位置。在Compose
版本的ConstraintLayout
中,可以主动创建引用并绑定至某个具体组件上,从而实现资源ID
相似的功能。每个组件都可以利用其他组件的引用获取到其他组件的摆放位置信息,从而确定自己应摆放的位置。
在Compose
中有两种创建引用的方式:createRef()
和createRefs()
。字面意思非常清楚,createRef()
每次只会创建一个引用,而createRefs()
每次可以创建多个引用(最多16个)。
kotlin
ConstraintLayout { //ConstraintLayoutScope作用域内才写的出来
//createRef
val portraitImageRef = remember {
createRef()
}
val userNameTextRef = remember {
createRef()
}
//createRefs
val (portraitImageRef,userNameTextRef) = remember{ createRefs()}
}
接下来可以使用Modifier.constrainAs()
修饰符将前面创建的引用绑定到某个具体组件上。可以在constrainAs
尾部Lambda
内指定组件的约束信息。值得注意的是,我们只能在ConstraintLayout
尾部的Lambda
中使用createRef()
、createRefs()
创建引用,并使用Modifier.constrainAs()
来绑定引用,这是因为ConstrainScope
尾部Lambda
的Reciever
是一个ConstraintLayoutScope
作用域对象。
而Modifier.constrainsAs()
尾部Lambda
是一个ConstrainScope
作用域对象,可以在其中获取到当前组件的parent
、top
、bottom
、start
、end
等信息,并使用linkTo
指定组件约束。
下面是一个简单的例子,让图片二排列在图片一的右下角:
kotlin
@SuppressLint("RememberReturnType")
@Composable
fun Greeting() {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
//createRef
val imageOneRef = remember { //创建图片一的id
createRef()
}
val imageTwoRef = remember { //创建图片二的id
createRef()
}
//图片一
Image(
painter = painterResource(id = R.mipmap.rabit2),
contentDescription = null,
modifier = Modifier
.size(150.dp)
.constrainAs(imageOneRef) { //图片一组件绑定id
//ConstrainScope作用域内
top.linkTo(parent.top) //定义位置,这里还能加margin
start.linkTo(parent.start) //定义位置,这里还能加margin
})
//图片二
Image(
painter = painterResource(id = R.mipmap.rabit2),
contentDescription = null,
modifier = Modifier
.size(150.dp)
.constrainAs(imageTwoRef) { //图片二组件绑定id
//ConstrainScope作用域内
top.linkTo(imageOneRef.bottom) //定义位置,这里还能加margin
start.linkTo(imageOneRef.end) //定义位置,这里还能加margin
})
}
}
UI效果
也可以在ConstrainScope
作用域中指定组件的宽高信息,在ConstrainScope
中直接设置width
与height
即可,有几个可选值可供使用,如下表所示:
Dimension可选值 | 描述 |
---|---|
wrapContent() |
实际尺寸为根据内容自适应的尺寸 |
matchParent() |
实际尺寸为铺满整父组件的尺寸 |
fillToConstraints() |
实际尺寸为根据约束信息拉伸后的尺寸 |
preferredWrapContent() |
如果剩余空间大于根据内容自适应的尺寸时,实际尺寸为自适应的尺寸。如果剩余空间小于内容自适应的尺寸时,实际尺寸则为剩余空间的尺寸 |
ratio (String) |
根据字符串计算实际尺寸所占比率,例如"1:2" |
percent (Float) |
根据浮点数计算实际尺寸所占比率 |
value (Dp) |
将尺寸设置为固定值 |
preferredValue (Dp) |
如果剩余空间大于固定值时,实际尺寸为固定值。如果剩余空间小于固定值时,实际尺寸则为剩余空间的尺寸 |
下面是一个例子,当文本过长时可以通过设置end
来指定组件最大所允许的宽度,并将width
设置为preferredWrapContent
,这意味着当文本较短时,实际宽度会随着长度进行自适应调整:
kotlin
@SuppressLint("RememberReturnType")
@Composable
fun Greeting() {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
//createRef
val imageRef = remember {
createRef()
}
val textRef = remember {
createRef()
}
Image(
painter = painterResource(id = R.mipmap.rabit2),
contentDescription = null,
modifier = Modifier.constrainAs(imageRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
width = Dimension.value(200.dp)
height = Dimension.value(200.dp)
})
Text(
text = "这是一个超长的文本一个超长的文本",
fontSize = 14.sp,
modifier = Modifier
.size(150.dp)
.constrainAs(textRef) {
//ConstrainScope作用域
top.linkTo(parent.top)
start.linkTo(imageRef.end)
end.linkTo(parent.end)
width = Dimension.preferredWrapContent //宽度自适应
})
}
}
UI效果
Compose
版本的ConstraintLayout
同样也继承了一些优质特性,例如Barrier
、Guideline
、Chain
等,方便我们完成各种复杂场景的布局需求,接下来将逐一进行介绍。
2、Barrier分界线
依托单个控件或多个控件的位置虚拟一条分界线,用于对齐等操作。下面是一个例子:
kotlin
@SuppressLint("RememberReturnType")
@Composable
fun Greeting() {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
//createRef
val (textOneRef, textTwoRef, imageRef) = remember {
createRefs()
}
//依托单个控件或多个控件的位置虚拟一条分界线,用于对齐等操作
//在二个Text中较长的那个的结尾创建分界线
val barrier = createEndBarrier(textOneRef, textTwoRef)
//较长文本
Text(
text = "三个字",
fontSize = 14.sp,
modifier = Modifier
.constrainAs(textOneRef) {
//ConstrainScope作用域
top.linkTo(parent.top)
start.linkTo(parent.start)
width = Dimension.preferredWrapContent
})
//较短文本
Text(
text = "二字",
fontSize = 14.sp,
modifier = Modifier
.constrainAs(textTwoRef) {
//ConstrainScope作用域
top.linkTo(textOneRef.bottom)
start.linkTo(parent.start)
width = Dimension.preferredWrapContent
})
Image(
painter = painterResource(id = R.mipmap.rabit2),
contentDescription = null,
modifier = Modifier
.size(200.dp)
.constrainAs(imageRef) {
//ConstrainScope作用域
top.linkTo(parent.top)
start.linkTo(barrier) //依托分界线对齐
width = Dimension.preferredWrapContent
}
)
}
}
UI效果
3、Guideline引导线
Barrier
分界线是需要依赖其他控件,从而确定自身位置的。而Guideline
不依赖任何引用,凭空创建出一条引导线。比如下面的例子,可以使用createGuidelineFromTop
创建从顶部出发的引导线:
kotlin
val guideline = createGuidelineFromTop(0.2F)
还有很多种其他方式,按需选用。
4、Chain链接约束
ConstraintLayout
另一个非常好用的特性就是Chain
链接约束,通过链接约束可以允许多个组件平均分配布局空间,这个功能类似于weight
修饰符。下面是一个例子:
kotlin
@SuppressLint("RememberReturnType")
@Composable
fun Greeting() {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
//createRef
val (imageOneRef, imageTwoRef) = remember {
createRefs()
}
//创建Chain,设置chainStyle
createVerticalChain(imageOneRef, imageTwoRef, chainStyle = ChainStyle.Spread)
Image(
painter = painterResource(id = R.mipmap.rabit2),
contentDescription = null,
modifier = Modifier
.size(200.dp)
.constrainAs(imageOneRef) { //设置id即可
//ConstrainScope作用域
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
Image(
painter = painterResource(id = R.mipmap.rabit2),
contentDescription = null,
modifier = Modifier
.size(200.dp)
.constrainAs(imageTwoRef) { //设置id即可
//ConstrainScope作用域
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
}
}
UI效果
重点看一下三种ChainStyle
:
Spread
:链条中每个元素平分整个parent
空间。SpreadInside
:链条中首尾元素紧贴边界,剩下每个元素平分整个parent
空间。Packed
:链条中所有元素聚集到中间。
六、Pager
Pager
即传统 View
体系中 ViewPager
的替代,但在使用上大大降低了复杂度。它包括 VerticalPager
和 HorizontalPager
两类,分别对应纵向和横向的滑动。Pager
的底层基于 LazyColumn/LazyRow
实现,在使用上也基本与它们等同。
1、HorizontalPager的参数
以HorizontalPager
为例说明一下参数:
kotlin
@Composable
@ExperimentalFoundationApi
fun HorizontalPager(
pageCount: Int, //页面的数量
modifier: Modifier = Modifier,
state: PagerState = rememberPagerState(), //Pager的状态,通常使用`rememberPagerState`来创建
contentPadding: PaddingValues = PaddingValues(0.dp), //内容的内边距
pageSize: PageSize = PageSize.Fill, //页面的大小,可以是PageSize.Fill(填充满Pager)或PageSize.WrapContent(根据内容自适应大小)
beyondBoundsPageCount: Int = 0, //在Pager边界之外预加载的页面数量
pageSpacing: Dp = 0.dp, //页面之间的间距
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, //垂直对齐方式
flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state), //滑动行为,用于定义Pager的滑动效果
userScrollEnabled: Boolean = true, //是否允许用户滚动Pager
reverseLayout: Boolean = false, //是否以反向布局显示页面
key: ((index: Int) -> Any)? = null, //用于为页面提供唯一键的函数
pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection(
Orientation.Horizontal
), //页面嵌套滚动连接,用于处理嵌套滚动事件
pageContent: @Composable (page: Int) -> Unit //用于绘制每个页面的内容的Composable函数
) {...}
2、最简单的使用案例
下面是一个简单使用的例子:
kotlin
// 显示 3 个项目
HorizontalPager(pageCount = 3) { page ->
// 每一页的内容,比如显示个文本
Text(
text = "Page: $page",
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(color = Color.LightGray)
)
}
UI效果
3、自定义指示器
如果需要带指示器,下面是一个自定义指示器的例子:
kotlin
enum class Page(val value: String) {
LIFE("生活"),
FOOD("美食"),
SCIENCE("科技")
}
private val pages = Page.values()
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Greeting() {
val pagerState = rememberPagerState()
Column(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
) {
HorizontalPager(
pageCount = 3,
state = pagerState
) { position ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(Color.Cyan),
contentAlignment = Alignment.Center
) {
Text(text = pages[position].value)
}
}
//自定义指示器
CustomIndicator(
pagerState = pagerState,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(10.dp),
indicatorCount = pages.size
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CustomIndicator(
pagerState: PagerState,
modifier: Modifier = Modifier,
activeColor: Color = MaterialTheme.colors.primary,
inactiveColor: Color = Color.LightGray,
indicatorWidth: Dp = 10.dp,
indicatorHeight: Dp = 5.dp,
spacing: Dp = 5.dp,
indicatorShape: Shape = CircleShape,
indicatorCount: Int
) {
val spacingPx = LocalDensity.current.run { spacing.roundToPx() }
Box(
modifier = modifier,
contentAlignment = Alignment.CenterStart
) {
Row(
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalAlignment = Alignment.CenterVertically,
) {
val indicatorModifier = Modifier
.background(color = inactiveColor, shape = indicatorShape)
//不能活动的索引的点
repeat(indicatorCount) {
Box(
indicatorModifier.size(
indicatorWidth,
indicatorHeight
)
)
}
}
//计算偏移量
val scrollPosition = (pagerState.currentPage + pagerState.currentPageOffsetFraction)
.coerceIn(
0f, (pages.size - 1).coerceAtLeast(0).toFloat()
)
//可以活动的索引点
Box(
Modifier
.offset {
IntOffset(
x = (spacingPx * scrollPosition + indicatorWidth.roundToPx() * scrollPosition).toInt(),
y = 0
)
}
.size(width = indicatorWidth, height = indicatorHeight)
.background(
color = activeColor,
shape = indicatorShape,
)
)
}
}
UI效果
4、使用TabRow构建指示器
TabRow
用于创建水平的选项卡栏,它通常用作与HorizontalPager
等组件配合当做指示器,用于显示和切换不同的页面。下面是一个案例:
kotlin
enum class Page(val value: String) {
LIFE("生活"),
FOOD("美食"),
SCIENCE("科技")
}
private val pages = Page.values()
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Greeting() {
val pagerState = rememberPagerState()
val animationScope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
) {
//TabRow指示器
TabRow(
selectedTabIndex = pagerState.currentPage,
modifier = Modifier.fillMaxWidth(),
backgroundColor = Color.LightGray
) {
pages.forEachIndexed { index, item ->
Tab(
selected = index == pagerState.currentPage,
text = { Text(item.value) },
onClick = {
animationScope.launch {
pagerState.animateScrollToPage(index)
}
}
)
}
}
//HorizontalPager
HorizontalPager(
pageCount = 3,
state = pagerState
) { position ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(Color.Cyan),
contentAlignment = Alignment.Center
) {
Text(text = pages[position].value)
}
}
}
}
UI效果
七、SubcomposeLayout
1、固有特性测量
(1)固有特性测量是什么
Compose
中的每个 UI
组件是不允许多次进行测量的,多次测量在运行时会抛异常,禁止多次测量的好处是为了提高性能,但在很多场景中多次测量子 UI
组件是有意义的。在 Jetpack Compose
代码实验室中就提供了这样一种场景,我们希望中间分割线高度与两边文案高的一边保持相等。下图所示:
为实现这个需求,官方所提供的设计方案是希望父组件可以预先获取到两边的文案组件高度信息,然后计算两边高度的最大值即可确定当前父组件的高度值,此时仅需将分割线高度值铺满整个父组件即可。
为了实现父组件预先获取文案组件高度信息从而确定自身的高度信息,Compose
为开发者们提供了固有特性测量机制,允许开发者在每个子组件正式测量前能获取到每个子组件的宽高等信息。
(2)在基础组件中使用固有特性测量
在上面所提到的例子中父组件所提供的能力使用基础组件中的 Row
组件即可承担,我们仅需为 Row
组件高度设置固有特性测量即可。我们使用 Modifier.height(IntrinsicSize.Min)
即可为高度设置固有特性测量。代码如下:
kotlin
@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
Row(modifier = modifier.height(IntrinsicSize.Min)) { //这里使用了固有特性测量
Text(
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.wrapContentWidth(Alignment.Start),
text = text1,
fontSize = 16.sp //字体大小不一样,高度不一样
)
Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp)) //高度为父布局最大高度
Text(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.wrapContentWidth(Alignment.End),
text = text2,
fontSize = 30.sp //字体大小不一样,高度不一样
)
}
}
//使用
Surface(border = BorderStroke(1.dp, Color.Blue) ) {
TwoTexts(text1 = "Hi,kotlin", text2 = "Hello,World")
}
UI效果
简单一句话总结就是:测量完所有的子再确定父。
2、SubcomposeLayout
SubcomposeLayout
可以做到将某个子组件的合成过程延迟至他所依赖的组件测量结束后进行,这也说明这个组件可以根据其他组件的测量信息确定自身的尺寸。先看一下SubcomposeLayout
的参数:
kotlin
@Composable
fun SubcomposeLayout(
modifier: Modifier = Modifier,
measurePolicy: SubcomposeMeasureScope.(Constraints) -> MeasureResult
) {...}
简单一句话总结就是:测量完依赖的子再确定子。
让我们再看一个在多语言中容易碰到的例子,下面的需求要求三个Tab宽度相等,在中文语境下没有问题,如下图:
切换到其他语言是没有办法保证Tab内的文本长度是相等的,如下图:
这个时候我们就需要找到最宽的那个子控件,然后让其他的子控件也设置同样的宽度,代码如下所示:
kotlin
@Composable
fun Greeting() {
val items = listOf("Processing", "Complete", "End")
ResizeHeightRow(
Modifier
.fillMaxWidth(), true, items.size
) {
items.forEachIndexed { index, itemText ->
Box(
modifier = Modifier
.background(Color.Red)
) {
Text(itemText)
}
}
}
}
@Composable
fun ResizeHeightRow(
modifier: Modifier,
resize: Boolean,
childSize: Int,
mainContent: @Composable () -> Unit
) {
//获取屏幕宽度,用于后续计算布局
val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp
val screenWidthPx = LocalDensity.current.run {
screenWidthDp.roundToPx()
}
SubcomposeLayout(modifier) { constraints ->
//调用子组合函数测量子组件大小
//这里通过SlotsEnum区分主要子组件和依赖组件
//主要组件用于测量最大尺寸,依赖组件用于生成实际布局
val mainPlaceables = subcompose(SlotsEnum.Main, mainContent).map {
//这里测量子Composables的宽高
it.measure(Constraints())
}
//这里找到子Composables的最大宽高
val maxSize = mainPlaceables.fold(IntSize.Zero) { currentMax, placeable ->
IntSize(
width = maxOf(currentMax.width, placeable.width),
height = maxOf(currentMax.height, placeable.height)
)
}
val resizedPlaceables: List<Placeable> =
subcompose(SlotsEnum.Dependent, mainContent).map {
if (resize) {
//用测量到的最大宽度重新设置子组件的测量宽高
it.measure(
Constraints(
minWidth = maxSize.width
)
)
} else {
//子组件测量自己的实际宽高
it.measure(Constraints())
}
}
//使用layout()和place()函数将可组合对象放在屏幕上
//测量完宽高后重新布局
layout(constraints.maxWidth, constraints.maxHeight) {
//组件之间的间隔空间
val space =
(screenWidthPx - maxSize.width * childSize) / (childSize - 1)
//集合遍历
resizedPlaceables.forEachIndexed { index, placeable ->
val widthStart =
resizedPlaceables.take(index).sumOf {
//宽度加上需要的间距
it.measuredWidth + space
}
//具体摆放位置
placeable.place(widthStart, 0)
}
}
}
}
//控件id
enum class SlotsEnum {
Main,
Dependent
}
UI效果
不知道有没有更好的实现方式,作为初学者在这方面积累还有限,如果有更好的方式欢迎留言评论!