官方文檔對(duì) @Stable 的注釋:
穩(wěn)定用于向組成編譯器傳達(dá)有關(guān)某種類型或函數(shù)的行為的某些保證可款。
當(dāng)應(yīng)用于類或接口時(shí),[Stable]表示以下條件必須為真:
1)對(duì)于兩個(gè)相同的實(shí)例克蚂,[equals]的結(jié)果將始終返回相同的結(jié)果闺鲸。
2)當(dāng)類型的公共財(cái)產(chǎn)發(fā)生變化時(shí),將通知組成埃叭。
3)所有公共財(cái)產(chǎn)類型都是穩(wěn)定的摸恍。
當(dāng)應(yīng)用于函數(shù)或?qū)傩詴r(shí),@Stable 注解表示如果傳入相同的參數(shù)赤屋,該函數(shù)將返回相同的結(jié)果立镶。僅當(dāng)參數(shù)和結(jié)果本身為[Stable],[Immutable]类早, 或原始的媚媒。
該注解所隱含的不變量由組合編譯器用于優(yōu)化,如果不滿足上述假設(shè)涩僻,則具有未定義的行為缭召。 所以除非他們確定滿足這些條件,否則不應(yīng)該使用此注解逆日。
舉一個(gè)代碼例子如下:
/**
* Main holder of our inset values.
*/
@Stable
class DisplayInsets {
/**
* Inset values which match [WindowInsetsCompat.Type.systemBars]
*/
val systemBars = Insets()
/**
* Inset values which match [WindowInsetsCompat.Type.systemGestures]
*/
val systemGestures = Insets()
/**
* Inset values which match [WindowInsetsCompat.Type.navigationBars]
*/
val navigationBars = Insets()
/**
* Inset values which match [WindowInsetsCompat.Type.statusBars]
*/
val statusBars = Insets()
/**
* Inset values which match [WindowInsetsCompat.Type.ime]
*/
val ime = Insets()
}
@Stable
class Insets {
/**
* The left dimension of these insets in pixels.
*/
var left by mutableStateOf(0)
internal set
/**
* The top dimension of these insets in pixels.
*/
var top by mutableStateOf(0)
internal set
/**
* The right dimension of these insets in pixels.
*/
var right by mutableStateOf(0)
internal set
/**
* The bottom dimension of these insets in pixels.
*/
var bottom by mutableStateOf(0)
internal set
/**
* Whether the insets are currently visible.
*/
var isVisible by mutableStateOf(true)
internal set
}
val InsetsAmbient = staticAmbientOf<DisplayInsets>()
/**
* Applies any [WindowInsetsCompat] values to [InsetsAmbient], which are then available
* within [content].
*
* @param consumeWindowInsets Whether to consume any [WindowInsetsCompat]s which are dispatched to
* the host view. Defaults to `true`.
*/
@Composable
fun ProvideDisplayInsets(
consumeWindowInsets: Boolean = true,
content: @Composable () -> Unit
) {
val view = ViewAmbient.current
val displayInsets = remember { DisplayInsets() }
onCommit(view) {
ViewCompat.setOnApplyWindowInsetsListener(view) { _, windowInsets ->
displayInsets.systemBars.updateFrom(windowInsets, WindowInsetsCompat.Type.systemBars())
displayInsets.systemGestures.updateFrom(
windowInsets,
WindowInsetsCompat.Type.systemGestures()
)
displayInsets.statusBars.updateFrom(windowInsets, WindowInsetsCompat.Type.statusBars())
displayInsets.navigationBars.updateFrom(
windowInsets,
WindowInsetsCompat.Type.navigationBars()
)
displayInsets.ime.updateFrom(windowInsets, WindowInsetsCompat.Type.ime())
if (consumeWindowInsets) WindowInsetsCompat.CONSUMED else windowInsets
}
// Add an OnAttachStateChangeListener to request an inset pass each time we're attached
// to the window
val attachListener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = v.requestApplyInsets()
override fun onViewDetachedFromWindow(v: View) = Unit
}
view.addOnAttachStateChangeListener(attachListener)
if (view.isAttachedToWindow) {
// If the view is already attached, we can request an inset pass now
view.requestApplyInsets()
}
onDispose {
view.removeOnAttachStateChangeListener(attachListener)
}
}
Providers(InsetsAmbient provides displayInsets) {
content()
}
}
/**
* Updates our mutable state backed [Insets] from an Android system insets.
*/
private fun Insets.updateFrom(windowInsets: WindowInsetsCompat, type: Int) {
val insets = windowInsets.getInsets(type)
left = insets.left
top = insets.top
right = insets.right
bottom = insets.bottom
isVisible = windowInsets.isVisible(type)
}
/**
* Apply additional space which matches the height of the status bars height along the top edge
* of the content.
*/
fun Modifier.statusBarsPadding() = composed {
insetsPadding(insets = InsetsAmbient.current.statusBars, top = true)
}
/**
* Apply additional space which matches the height of the navigation bars height
* along the [bottom] edge of the content, and additional space which matches the width of
* the navigation bars on the respective [left] and [right] edges.
*
* @param bottom Whether to apply padding to the bottom edge, which matches the navigation bars
* height (if present) at the bottom edge of the screen. Defaults to `true`.
* @param left Whether to apply padding to the left edge, which matches the navigation bars width
* (if present) on the left edge of the screen. Defaults to `true`.
* @param right Whether to apply padding to the right edge, which matches the navigation bars width
* (if present) on the right edge of the screen. Defaults to `true`.
*/
fun Modifier.navigationBarsPadding(
bottom: Boolean = true,
left: Boolean = true,
right: Boolean = true
) = composed {
insetsPadding(
insets = InsetsAmbient.current.navigationBars,
left = left,
right = right,
bottom = bottom
)
}
/**
* Declare the height of the content to match the height of the navigation bars, plus some
* additional height passed in via [additional]
*
* As an example, this could be used with `Spacer` to push content above the navigation bar
* and bottom app bars:
*
* ```
* Column {
* // Content to be drawn above navigation bars and bottom app bar (y-axis)
*
* Spacer(Modifier.statusBarHeightPlus(48.dp))
* }
* ```
*
* Internally this matches the behavior of the [Modifier.height] modifier.
*
* @param additional Any additional height to add to the status bars size.
*/
fun Modifier.navigationBarsHeightPlus(additional: Dp) = composed {
InsetsSizeModifier(
insets = InsetsAmbient.current.navigationBars,
heightSide = VerticalSide.Bottom,
additionalHeight = additional
)
}
enum class HorizontalSide {
Left,
Right
}
enum class VerticalSide {
Top,
Bottom
}
/**
* Allows conditional setting of [insets] on each dimension.
*/
private fun Modifier.insetsPadding(
insets: Insets,
left: Boolean = false,
top: Boolean = false,
right: Boolean = false,
bottom: Boolean = false
) = this then InsetsPaddingModifier(insets, left, top, right, bottom)
private data class InsetsPaddingModifier(
private val insets: Insets,
private val applyLeft: Boolean = false,
private val applyTop: Boolean = false,
private val applyRight: Boolean = false,
private val applyBottom: Boolean = false
) : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureScope.MeasureResult {
val left = if (applyLeft) insets.left else 0
val top = if (applyTop) insets.top else 0
val right = if (applyRight) insets.right else 0
val bottom = if (applyBottom) insets.bottom else 0
val horizontal = left + right
val vertical = top + bottom
val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
val width = (placeable.width + horizontal)
.coerceIn(constraints.minWidth, constraints.maxWidth)
val height = (placeable.height + vertical)
.coerceIn(constraints.minHeight, constraints.maxHeight)
return layout(width, height) {
placeable.place(left, top)
}
}
}
private data class InsetsSizeModifier(
private val insets: Insets,
private val widthSide: HorizontalSide? = null,
private val additionalWidth: Dp = 0.dp,
private val heightSide: VerticalSide? = null,
private val additionalHeight: Dp = 0.dp
) : LayoutModifier {
private val Density.targetConstraints: Constraints
get() {
val additionalWidthPx = additionalWidth.toIntPx()
val additionalHeightPx = additionalHeight.toIntPx()
return Constraints(
minWidth = additionalWidthPx + when (widthSide) {
HorizontalSide.Left -> insets.left
HorizontalSide.Right -> insets.right
null -> 0
},
minHeight = additionalHeightPx + when (heightSide) {
VerticalSide.Top -> insets.top
VerticalSide.Bottom -> insets.bottom
null -> 0
},
maxWidth = when (widthSide) {
HorizontalSide.Left -> insets.left + additionalWidthPx
HorizontalSide.Right -> insets.right + additionalWidthPx
null -> Constraints.Infinity
},
maxHeight = when (heightSide) {
VerticalSide.Top -> insets.top + additionalHeightPx
VerticalSide.Bottom -> insets.bottom + additionalHeightPx
null -> Constraints.Infinity
}
)
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureScope.MeasureResult {
val wrappedConstraints = targetConstraints.let { targetConstraints ->
val resolvedMinWidth = if (widthSide != null) {
targetConstraints.minWidth
} else {
constraints.minWidth.coerceAtMost(targetConstraints.maxWidth)
}
val resolvedMaxWidth = if (widthSide != null) {
targetConstraints.maxWidth
} else {
constraints.maxWidth.coerceAtLeast(targetConstraints.minWidth)
}
val resolvedMinHeight = if (heightSide != null) {
targetConstraints.minHeight
} else {
constraints.minHeight.coerceAtMost(targetConstraints.maxHeight)
}
val resolvedMaxHeight = if (heightSide != null) {
targetConstraints.maxHeight
} else {
constraints.maxHeight.coerceAtLeast(targetConstraints.minHeight)
}
Constraints(
resolvedMinWidth,
resolvedMaxWidth,
resolvedMinHeight,
resolvedMaxHeight
)
}
val placeable = measurable.measure(wrappedConstraints)
return layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
) = measurable.minIntrinsicWidth(height).let {
val constraints = targetConstraints
it.coerceIn(constraints.minWidth, constraints.maxWidth)
}
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
) = measurable.maxIntrinsicWidth(height).let {
val constraints = targetConstraints
it.coerceIn(constraints.minWidth, constraints.maxWidth)
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
) = measurable.minIntrinsicHeight(width).let {
val constraints = targetConstraints
it.coerceIn(constraints.minHeight, constraints.maxHeight)
}
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
) = measurable.maxIntrinsicHeight(width).let {
val constraints = targetConstraints
it.coerceIn(constraints.minHeight, constraints.maxHeight)
}
}
Jetpack Compose 是谷歌在2019 Google i/o 大會(huì)上發(fā)布的新的庫(kù)嵌巷。可以用更少更直觀的代碼創(chuàng)建 View屏富,還有更強(qiáng)大的功能晴竞,以及還能提高開(kāi)發(fā)速度。 說(shuō)實(shí)話狠半,View/Layout 的模式對(duì)安卓工程師來(lái)說(shuō)太過(guò)于熟悉噩死,對(duì)于學(xué)習(xí)曲線陡峭的 Jetpack Compose 能不能很好的普及還是有所擔(dān)心颤难。
如果使用 Jetpack Compose 呢,以下做些簡(jiǎn)單介紹:
在模塊中的 build.gradle 文件根據(jù)自己的需要新增下列的庫(kù)的依賴
// compose
composeVersion : '1.0.0-alpha03',
// compose
implementation "androidx.compose.ui:ui:$versions.composeVersion"
implementation "androidx.compose.material:material:$versions.composeVersion"
implementation "androidx.compose.material:material-icons-extended:$versions.composeVersion"
implementation "androidx.compose.foundation:foundation:$versions.composeVersion"
implementation "androidx.compose.foundation:foundation-layout:$versions.composeVersion"
implementation "androidx.compose.animation:animation:$versions.composeVersion"
implementation "androidx.compose.runtime:runtime:$versions.composeVersion"
implementation "androidx.compose.runtime:runtime-livedata:$versions.composeVersion"
implementation "androidx.ui:ui-tooling:$versions.composeVersion"
androidTestImplementation "androidx.ui:ui-test:$versions.composeVersion"
還有在模塊的 build.gradle 文件中新增下列的設(shè)置已维。
android {
...
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion "${compose_version}"
kotlinCompilerVersion versions.kotlin
}
}
@Compose
所有關(guān)于構(gòu)建 View 的方法都必須添加 @Compose 的注解才可以行嗤。并且 @Compose 跟協(xié)程的 Suspend 的使用方法比較類似,被 @Compose 的注解的方法只能在同樣被 @Comopse 注解的方法中才能被調(diào)用。
/**
* A wrapper around [CoilImage] setting a default [contentScale] and loading indicator for loading disney poster images.
*/
@Composable
fun NetworkImage(
url: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop
) {
CoilImageWithCrossfade(
data = url,
modifier = modifier,
contentScale = contentScale,
loading = {
ConstraintLayout(
modifier = Modifier.fillMaxSize()
) {
val indicator = createRef()
CircularProgressIndicator(
modifier = Modifier.constrainAs(indicator) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
}
}
)
@Preview
加上 @Preview 注解的方法可以在不運(yùn)行 App 的情況下就可以確認(rèn)布局的情況垛耳。
@Preview 的注解中比較常用的參數(shù)如下:
- name: String: 為該 Preview 命名栅屏,該名字會(huì)在布局預(yù)覽中顯示。
- showBackground: Boolean: 是否顯示背景堂鲜,true 為顯示栈雳。
- backgroundColor: Long: 設(shè)置背景的顏色。
- showDecoration: Boolean: 是否顯示 Statusbar 和 Toolbar缔莲,true 為顯示哥纫。
- group: String: 為該 Preview 設(shè)置 group 名字,可以在 UI 中以 group 為單位顯示痴奏。
- fontScale: Float: 可以在預(yù)覽中對(duì)字體放大蛀骇,范圍是從0.01。
- widthDp: Int: 在 Compose 中渲染的最大寬度读拆,單位為dp擅憔。
- heightDp: Int: 在 Compose 中渲染的最大高度,單位為dp檐晕。
上面的參數(shù)都是可選參數(shù)暑诸,還有像背景設(shè)置等的參數(shù)并不是對(duì)實(shí)際的 App 進(jìn)行設(shè)置,只是對(duì) Preview 中的背景進(jìn)行設(shè)置棉姐,為了更容易看清布局屠列。
@Preview(showBackground = true, name = "Home UI", showDecoration = true)
@Composable
fun DefaultPreview() {
MyApplicationTheme(darkTheme = false) {
Greeting("Android")
}
}
在 IDE 的右上角有 Code,Split , Design 三個(gè)選項(xiàng)伞矩。分別是只顯示代碼笛洛,同時(shí)顯示代碼和布局和只顯示布局。
當(dāng)更改跟 UI 相關(guān)的代碼時(shí)乃坤,會(huì)顯示如下圖的一個(gè)橫條通知苛让,點(diǎn)擊 Build&Refresh 即可更新顯示所更改代碼的UI。
setContent
setContent 的作用是和 Layout/View 中的 setContentView 是一樣的湿诊。
setContent 的方法也是有 @Compose 注解的方法狱杰。所以,在 setContent 中寫(xiě)入關(guān)于 UI 的 @Compopse 方法厅须,即可在 Activity 中顯示仿畸。
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@VisibleForTesting val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// observe toast.
viewModel.toast.observe(this) {
Toast.makeText(this, it, Toast.LENGTH_SHORT).show()
}
// fetch disney posters.
viewModel.fetchDisneyPosterList()
// set disney contents.
setContent {
DisneyComposeTheme {
DisneyMain(
viewModel = viewModel,
backDispatcher = onBackPressedDispatcher
)
}
}
}
}
*Theme
在創(chuàng)建新的 Compose 項(xiàng)目時(shí)會(huì)自動(dòng)創(chuàng)建一個(gè)項(xiàng)目名+Theme 的 @Compose 方法。 我們可以通過(guò)更改顏色來(lái)完成對(duì)主題顏色的設(shè)置。 生成的 Theme 方法的代碼如下错沽。
private val DarkColorPalette = darkColors(
background = background,
onBackground = background800,
primary = purple200,
primaryVariant = purple500,
secondary = purple500,
onPrimary = Color.White,
onSecondary = Color.White
)
private val LightColorPalette = lightColors(
background = Color.White,
onBackground = Color.White,
surface = Color.White,
primary = purple200,
primaryVariant = purple500,
secondary = purple500,
onPrimary = Color.White,
onSecondary = Color.White
)
@Composable
fun DisneyComposeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
val typography = if (darkTheme) {
DarkTypography
} else {
LightTypography
}
MaterialTheme(
colors = colors,
typography = typography,
shapes = shapes,
content = content
)
}
Theme方法中有正常主題和Dark主題的顏色設(shè)置簿晓,里面還有關(guān)于MeterialTheme的設(shè)置。
關(guān)于Theme方法的用法如下千埃。
// set disney contents.
setContent {
DisneyComposeTheme {
DisneyMain(
viewModel = viewModel,
backDispatcher = onBackPressedDispatcher
)
}
}
在 DisneyComposeTheme 里面的所有 UI 方法都會(huì)應(yīng)用上述主題中指定的顏色憔儿。
*Modifier
Modifier 是各個(gè) Compose 的 UI 組件一定會(huì)用到的一個(gè)類。它是被用于設(shè)置 UI 的擺放位置放可,padding 等信息的類谒臼。關(guān)于 Modifier 相關(guān)的設(shè)置實(shí)在是太多,在這里只介紹會(huì)經(jīng)常用到的耀里。
- padding 設(shè)置各個(gè) UI 的 padding蜈缤。padding 的重載的方法一共有四個(gè)
Modifier.padding(10.dp) // 給上下左右設(shè)置成同一個(gè)值
Modifier.padding(10.dp, 11.dp, 12.dp, 13.dp) // 分別為上下左右設(shè)值
Modifier.padding(10.dp, 11.dp) // 分別為上下和左右設(shè)值
Modifier.padding(InnerPadding(10.dp, 11.dp, 12.dp, 13.dp))// 分別為上下左右設(shè)值
- plus 可以把其他的Modifier加入到當(dāng)前的Modifier中。
Modifier.plus(otherModifier) // 把otherModifier的信息加入到現(xiàn)有的modifier中
這里設(shè)置的值必須為 dp备韧,Compose為我們?cè)?Int 中擴(kuò)展了一個(gè)方法 dp劫樟,幫我們轉(zhuǎn)換成 dp。
- fillMaxHeight, fillMaxWidth, fillMaxSize 類似于 match_parent ,填充整個(gè)父 layout 织堂。
例如:Modifier.fillMaxHeight() // 填充整個(gè)高度
@Composable
fun PosterDetails(
viewModel: MainViewModel,
pressOnBack: () -> Unit
) {
val details: Poster? by viewModel.posterDetails.observeAsState()
details?.let { poster ->
ScrollableColumn(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxHeight()
) {
ConstraintLayout {
val (arrow, image, title, content) = createRefs()
NetworkImage(
url = poster.poster,
modifier = Modifier.constrainAs(image) {
top.linkTo(parent.top)
}.fillMaxWidth()
.aspectRatio(0.85f)
)
Text(
text = poster.name,
style = MaterialTheme.typography.h1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.constrainAs(title) {
top.linkTo(image.bottom)
}.padding(start = 16.dp, top = 16.dp)
)
Text(
text = poster.description,
style = MaterialTheme.typography.body2,
modifier = Modifier.constrainAs(content) {
top.linkTo(title.bottom)
}.padding(16.dp)
)
Icon(
asset = Icons.Filled.ArrowBack,
tint = Color.White,
modifier = Modifier.constrainAs(arrow) {
top.linkTo(parent.top)
}.padding(12.dp)
.clickable(onClick = { pressOnBack() })
)
}
}
}
}
- width, heigh, size 設(shè)置 Content 的寬度和高度。
Modifier.widthIn(2.dp) // 設(shè)置最大寬度
Modifier.heightIn(3.dp) // 設(shè)置最大高度
Modifier.sizeIn(4.dp, 5.dp, 6.dp, 7.dp) // 設(shè)置最大最小的寬度和高度
- gravity 在 Column 中元素的位置
Modifier.gravity(Alignment.CenterHorizontally) // 橫向居中
Modifier.gravity(Alignment.Start) // 橫向居左
Modifier.gravity(Alignment.End) // 橫向居右
- rtl, ltr 開(kāi)始布局UI的方向奶陈。
Modifier.rtl // 從右到左
Modifier.ltr // 從左到右
- Modifier的方法都返回Modifier的實(shí)例的鏈?zhǔn)秸{(diào)用易阳,所以只要連續(xù)調(diào)用想要使用的方法即可。
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!", modifier = Modifier.padding(20.dp).fillMaxSize())
}
*Column吃粒,Row
正如其名字一樣潦俺,Column 和 Row 可以理解為在 View/Layout 體系中的縱向和橫向的 ViewGroup。
需要傳入的參數(shù)一共有四個(gè)
- Modifier 用上述的方法傳入已經(jīng)按需求設(shè)置好的 Modifier 即可
- Arrangement.Horizontal, Arrangement.Vertical 需要給 Row 傳入 Arrangement.Horizontal徐勃,為 Column 傳入Arrangement.Vertical事示。
這些值決定如何布置內(nèi)部 UI 組件。
可傳入的值為 Center, Start, End, SpaceEvenly, SpaceBetween, SpaceAround僻肖。
重點(diǎn)解釋一下SpaceEvenly, SpaceBetween, SpaceAround肖爵。
SpaceEvenly:各個(gè)元素間的空隙為等比例。
SpaceBetween:第一元素前和最后一個(gè)元素之后沒(méi)有空隙臀脏,所有空隙都按等比例放入各個(gè)元素之間劝堪。
SpaceAround:把整體中一半的空隙平分的放入第一元素前和最后一個(gè)元素之后,剩余的一半等比例的放入各個(gè)元素之間揉稚。 - Alignment.Vertical, Alignment.Horizontal
需要給 Row 傳入 Alignment.Vertical秒啦,為 Column 傳入 Alignment.Horizontal。
使用方法和 Modifier 的 gravity 中傳入?yún)?shù)的用法是一樣的搀玖,這里就略過(guò)了余境。 - @Composable ColumnScope.() -> Unit 需要傳入標(biāo)有 @Compose 的 UI 方法。但是這里我們會(huì)有 lamda 函數(shù)的寫(xiě)法來(lái)實(shí)現(xiàn)。
@Composable
fun RadioPosters(
posters: List<Poster>,
selectPoster: (Long) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.statusBarsPadding()
.background(MaterialTheme.colors.background)
) {
LazyColumnFor(
items = posters,
contentPadding = PaddingValues(4.dp),
) { poster ->
RadioPoster(
poster = poster,
selectPoster = selectPoster
)
}
}
}
Column {
Row(modifier = Modifier.ltr.fillMaxWidth(),horizontalArrangement = Arrangement.SpaceAround, verticalGravity = Alignment.Top) {
// ..,...
}
最后的話:
會(huì) flutter 的這個(gè)上手就超級(jí)快芳来,要是學(xué)會(huì)這個(gè)再去學(xué) flutter 上手也很快