深度解析 Jetpack Compose 布局

[更少的代碼古瓤、強(qiáng)大的工具和直觀的 Kotlin API膀藐,快速讓?xiě)?yīng)用生動(dòng)而精彩媚值。Compose 使用全新的組件——可組合項(xiàng) (Composable) 來(lái)布局界面睦焕,使用修飾符 (Modifier) 來(lái)配置可組合項(xiàng)藐握。

本文會(huì)為您講解由可組合項(xiàng)和修飾符提供支持的組合布局模型,并深入探究其背后的工作原理以及它們的功能垃喊,讓您更好地了解所用布局和修飾符的工作方式猾普,和應(yīng)如何以及在何時(shí)構(gòu)建自定義布局,從而實(shí)現(xiàn)滿足確切應(yīng)用需求的設(shè)計(jì).

布局模型

Compose 布局系統(tǒng)的目標(biāo)是提供易于創(chuàng)建的布局本谜,尤其是自定義布局初家。這要求布局系統(tǒng)具備強(qiáng)大的功能,使開(kāi)發(fā)者能創(chuàng)建應(yīng)用所需的任何布局乌助,并且讓布局具備優(yōu)異的性能溜在。接下來(lái),我們來(lái)看看Compose 的布局模型是如何實(shí)現(xiàn)這些目標(biāo)的他托。

Jetpack Compose 可將狀態(tài)轉(zhuǎn)換為界面掖肋,這個(gè)過(guò)程分為三步: 組合、布局赏参、繪制志笼。組合階段執(zhí)行可組合函數(shù),這些函數(shù)可以生成界面登刺,從而創(chuàng)建界面樹(shù)籽腕。例如嗡呼,下圖中的 SearchResult 函數(shù)會(huì)生成對(duì)應(yīng)的界面樹(shù):

△ 可組合函數(shù)生成對(duì)應(yīng)的界面樹(shù)

可組合項(xiàng)中可以包含邏輯和控制流纸俭,因此可以根據(jù)不同的狀態(tài)生成不同的界面樹(shù)。在布局階段南窗,Compose 會(huì)遍歷界面樹(shù)揍很,測(cè)量界面的各個(gè)部分郎楼,并將每個(gè)部分放置在屏幕 2D 空間中。也就是說(shuō)窒悔,每個(gè)節(jié)點(diǎn)決定了其各自的寬度呜袁、高度以及 x 和 y 坐標(biāo)。在繪制階段简珠,Compose 將再次遍歷這棵界面樹(shù)手销,并渲染所有元素燎潮。本文將深入探討布局階段。布局階段又細(xì)分為兩個(gè)階段: 測(cè)量和放置。這相當(dāng)于 View 系統(tǒng)中的 onMeasure 和 onLayout舷夺。但在 Compose 中,這兩個(gè)階段會(huì)交叉進(jìn)行箱熬,因此我們把它看成一個(gè)布局階段钧萍。將界面樹(shù)中每個(gè)節(jié)點(diǎn)布局的過(guò)程分為三步: 每個(gè)節(jié)點(diǎn)必須測(cè)量自身的所有子節(jié)點(diǎn),再?zèng)Q定自身的尺寸脱货,然后放置其子節(jié)點(diǎn)岛都。如下例,單遍即可對(duì)整個(gè)界面樹(shù)完成布局振峻。

△ 布局過(guò)程

其過(guò)程簡(jiǎn)述如下:

  1. 測(cè)量根布局 Row臼疫;
  2. Row 測(cè)量它的第一個(gè)子節(jié)點(diǎn) Image;
  3. 由于 Image 是一個(gè)不含子節(jié)點(diǎn)的葉子節(jié)點(diǎn)铺韧,它會(huì)測(cè)量自身尺寸并加以報(bào)告多矮,還會(huì)返回有關(guān)如何放置其子節(jié)點(diǎn)的指令。Image 的葉子節(jié)點(diǎn)通常是空節(jié)點(diǎn)哈打,但所有布局都會(huì)在設(shè)置其尺寸的同時(shí)返回這些放置指令塔逃;
  4. Row 測(cè)量它的第二個(gè)子節(jié)點(diǎn) Column;
  5. Column 測(cè)量其子節(jié)點(diǎn)料仗,首先測(cè)量第一個(gè)子節(jié)點(diǎn) Text湾盗;
  6. Text 測(cè)量并報(bào)告其尺寸以及放置指令;
  7. Column 測(cè)量第二個(gè)子節(jié)點(diǎn) Text立轧;
  8. Text 測(cè)量并報(bào)告其尺寸以及放置指令格粪;
  9. Column 測(cè)量完其子節(jié)點(diǎn),可以決定其自身的尺寸和放置邏輯氛改;
  10. Row 根據(jù)其所有子節(jié)點(diǎn)的測(cè)量結(jié)果決定其自身尺寸和放置指令帐萎。

測(cè)量完所有元素的尺寸后,將再次遍歷界面樹(shù)胜卤,并且會(huì)在放置階段執(zhí)行所有放置指令疆导。

Layout 可組合項(xiàng)

我們已經(jīng)了解這個(gè)過(guò)程涉及的步驟,接下來(lái)看一下它的實(shí)現(xiàn)方式葛躏。先看看組合階段澈段,我們采用 Row悠菜、Column、Text 等更高級(jí)別的可組合項(xiàng)來(lái)表示界面樹(shù)败富,每個(gè)高級(jí)別的可組合項(xiàng)實(shí)際上都是由低級(jí)別的可組合項(xiàng)構(gòu)建而成悔醋。以 Text 為例,可以發(fā)現(xiàn)它由若干更低級(jí)別的基礎(chǔ)構(gòu)建塊組成兽叮,而這些可組合項(xiàng)都會(huì)包含一個(gè)或多個(gè) Layout 可組合項(xiàng)芬骄。

△ 每個(gè)可組合項(xiàng)都包含一個(gè)或多個(gè) Layout

Layout 可組合項(xiàng)是 Compose 界面的基礎(chǔ)構(gòu)建塊,它會(huì)生成 LayoutNode鹦聪。在 Compose 中德玫,界面樹(shù),或者說(shuō)組合 (composition) 是一棵 LayoutNode 樹(shù)椎麦。以下是 Layout 可組合項(xiàng)的函數(shù)簽名:

@Composable
fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    …
}

其中宰僧,content 是可以容納任何子可組合項(xiàng)的槽位,出于布局需要观挎,content 中也會(huì)包含子 Layout琴儿。modifier 參數(shù)所指定的修飾符將應(yīng)用于該布局,這在下文中會(huì)詳細(xì)介紹嘁捷。measurePolicy 參數(shù)是 MeasurePolicy 類型造成,它是一個(gè)函數(shù)式接口,指定了布局測(cè)量和放置項(xiàng)目的方式雄嚣。一般情況下晒屎,如需實(shí)現(xiàn)自定義布局的行為,您要在代碼中實(shí)現(xiàn)該函數(shù)式接口:

@Composable
fun MyCustomLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
         modifier = modifier,
         content = content
    ) { measurables: List<Measurable>,
         constraints: Constraints ->
        // TODO 測(cè)量和放置項(xiàng)目
   }
}

在 MyCustomLayout 可組合項(xiàng)中缓升,我們調(diào)用 Layout 函數(shù)并以 Trailing Lambda 的形式提供 MeasurePolicy 作為參數(shù)鼓鲁,從而實(shí)現(xiàn)所需的 measure 函數(shù)。該函數(shù)接受一個(gè) Constraints 對(duì)象來(lái)告知 Layout 它的尺寸限制港谊。Constraints 是一個(gè)簡(jiǎn)單類骇吭,用于限制 Layout 的最大和最小寬度與高度:

class Constraints {
    val minWidth: Int
    val maxWidth: Int
    val minHeight: Int
    val maxHeight: Int
}

measure 函數(shù)還會(huì)接受 List<Measurable> 作為參數(shù),這表示的是傳入的子元素歧寺。Measurable 類型會(huì)公開(kāi)用于測(cè)量項(xiàng)目的函數(shù)燥狰。如前所述,布局每個(gè)元素需要三步: 每個(gè)元素必須測(cè)量其所有子元素斜筐,并以此判斷自身尺寸龙致,再放置其子元素。其代碼實(shí)現(xiàn)如下:

@Composable
fun MyCustomLayout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier
) {
    Layout(
         modifier = modifier,
         content = content
    ) { measurables: List<Measurable>,
         constraints: Constraints ->
        // placeables 是經(jīng)過(guò)測(cè)量的子元素顷链,它擁有自身的尺寸值
        val placeables = measurables.map { measurable ->
            // 測(cè)量所有子元素目代,這里不編寫(xiě)任何自定義測(cè)量邏輯,只是簡(jiǎn)單地
            // 調(diào)用 Measurable 的 measure 函數(shù)并傳入 constraints
            measurable.measure(constraints)
        }
        val width = // 根據(jù) placeables 計(jì)算得出
        val height = // 根據(jù) placeables 計(jì)算得出
        // 報(bào)告所需的尺寸
        layout (width, height) {
            placeables.foreach { placeable ->
                // 通過(guò)遍歷將每個(gè)項(xiàng)目放置到最終的預(yù)期位置
                placeable.place(
                    x = …
                    y = …
                )
            }
        }
   }
}

上述代碼中使用了 Placeable 的 place 函數(shù),它還有一個(gè) placeRelative 函數(shù)可用于從右到左的語(yǔ)言設(shè)置中像啼,當(dāng)使用該函數(shù)時(shí),它會(huì)自動(dòng)對(duì)坐標(biāo)進(jìn)行水平鏡像潭苞。

請(qǐng)注意忽冻,API 在設(shè)計(jì)上可阻止您嘗試放置未經(jīng)測(cè)量的元素,place 函數(shù)只適用于 Placeable此疹,也就是 measure 函數(shù)的返回值僧诚。在 View 系統(tǒng)中,調(diào)用 onMeasure 以及 onLayout 的時(shí)機(jī)由您決定蝗碎,而且調(diào)用順序沒(méi)有強(qiáng)制要求湖笨,但這會(huì)產(chǎn)生一些微妙的 bug 以及行為上的差異。

自定義布局示例

MyColumn 示例

△ ColumnCompose

提供一個(gè) Column 組件用于縱向排布元素蹦骑。為了理解這個(gè)組件背后的工作方式及其使用 Layout 可組合項(xiàng)的方式慈省,我們來(lái)實(shí)現(xiàn)自己的一個(gè) Column。暫且將其命名為 MyColumn眠菇,其實(shí)現(xiàn)代碼如下:


@Composable
fun MyColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
         modifier = modifier,
         content = content
    ) { measurables, constraints ->
        // 測(cè)量每個(gè)項(xiàng)目并將其轉(zhuǎn)換為 Placeable
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }
        // Column 的高度是所有項(xiàng)目所測(cè)得高度之和
        val height = placeables.sumOf { it.height }
        // Column 的寬度則為內(nèi)部所含最寬項(xiàng)目的寬度
        val width = placeables.maxOf { it.width }
        // 報(bào)告所需的尺寸
        layout (width, height) {
            // 通過(guò)跟蹤 y 坐標(biāo)放置每個(gè)項(xiàng)目
            var y = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = y)
                // 按照所放置項(xiàng)目的高度增加 y 坐標(biāo)值
                y += placeable.height
            }
        }
    }
}

VerticalGrid 示例

△ VerticalGrid

我們?cè)賮?lái)看另一個(gè)示例: 構(gòu)建常規(guī)網(wǎng)格边败。其部分代碼實(shí)現(xiàn)如下:


@Composable
fun VerticalGrid(
    modifier: Modifier = Modifier,
    columns: Int = 2,
    content: @Composable () -> Unit
) {
    Layout(
        content = content,
         modifier = modifier
    ) { measurables, constraints ->
        val itemWidth = constraints.maxWidth / columns
        // 通過(guò) copy 函數(shù)保留傳遞下來(lái)的高度約束,但設(shè)置確定的寬度約束
        val itemConstraints = constraints.copy (
            minWidth = itemWidth,
            maxWidth = itemWidth,
        )        
        // 使用這些約束測(cè)量每個(gè)項(xiàng)目并將其轉(zhuǎn)換為 Placeable
        val placeables = measurables.map { it.measure(itemConstraints) }
        …
    }
}

在該示例中捎废,我們通過(guò) copy 函數(shù)創(chuàng)建了新的約束笑窜。這種為子節(jié)點(diǎn)創(chuàng)建新約束的概念就是實(shí)現(xiàn)自定義測(cè)量邏輯的方式。創(chuàng)建不同約束來(lái)測(cè)量子節(jié)點(diǎn)的能力是此模型的關(guān)鍵登疗,父節(jié)點(diǎn)與子節(jié)點(diǎn)之間并沒(méi)有協(xié)商機(jī)制排截,父節(jié)點(diǎn)會(huì)以 Constraints 的形式傳遞其允許子節(jié)點(diǎn)的尺寸范圍,只要子節(jié)點(diǎn)從該范圍中選擇了其尺寸辐益,父節(jié)點(diǎn)必須接受并處理子節(jié)點(diǎn)断傲。

這種設(shè)計(jì)的優(yōu)點(diǎn)在于我們可以單遍測(cè)量整棵界面樹(shù),并且禁止執(zhí)行多個(gè)測(cè)量循環(huán)智政。這是 View 系統(tǒng)中存在的問(wèn)題艳悔,嵌套結(jié)構(gòu)執(zhí)行多遍測(cè)量過(guò)程可能會(huì)讓葉子視圖上的測(cè)量次數(shù)翻倍,Compose 的設(shè)計(jì)能夠防止發(fā)生這種情況女仰。實(shí)際上猜年,如果您對(duì)某個(gè)項(xiàng)目進(jìn)行兩次測(cè)量,Compose 會(huì)拋出異常:

△ 重復(fù)測(cè)量某個(gè)項(xiàng)目時(shí) Compose 會(huì)拋出異常

布局動(dòng)畫(huà)示例

由于具備更強(qiáng)的性能保證疾忍,Compose 提供了新的可能性乔外,例如為布局添加動(dòng)畫(huà)。Layout composable 不僅可以創(chuàng)建通用布局一罩,還能創(chuàng)建出符合應(yīng)用設(shè)計(jì)需求的專用布局杨幼。以 Jetsnack 應(yīng)用中的自定義底部導(dǎo)航為例,在該設(shè)計(jì)中,如果某項(xiàng)目被選中差购,則顯示標(biāo)簽四瘫;如果未被選中,則只顯示圖標(biāo)欲逃。而且找蜜,設(shè)計(jì)還需要讓項(xiàng)目的尺寸和位置根據(jù)當(dāng)前選擇狀態(tài)執(zhí)行動(dòng)畫(huà)。

△ Jetsnack 應(yīng)用中的自定義底部導(dǎo)航

我們可以使用自定義布局來(lái)實(shí)現(xiàn)該設(shè)計(jì)稳析,從而對(duì)布局變化的動(dòng)畫(huà)處理進(jìn)行精確控制:


@Composable
fun BottomNavItem(
    icon: @Composable BoxScope.() -> Unit,
    text: @Composable BoxScope.() -> Unit,
    @FloatRange(from = 0.0, to = 1.0) animationProgress: Float
) {
    Layout(
        content = {
            // 將 icon 和 text 包裹在 Box 中
            // 這種做法能讓我們?yōu)槊總€(gè)項(xiàng)目設(shè)置 layoutId
            Box(
                modifier = Modifier.layoutId(“icon”)
                content = icon
            )
            Box(
                modifier = Modifier.layoutId(“text”)
                content = text
            )
        }
    ) { measurables, constraints ->
        // 通過(guò) layoutId 識(shí)別對(duì)應(yīng)的 Measurable洗做,比依賴項(xiàng)目的順序更可靠
        val iconPlaceable = measurables.first {it.layoutId == “icon” }.measure(constraints)
        val textPlaceable = measurables.first {it.layoutId == “text” }.measure(constraints)
 
        // 將放置邏輯提取到另一個(gè)函數(shù)中以提高代碼可讀性
        placeTextAndIcon(
            textPlaceable,
            iconPlaceable,
            constraints.maxWidth,
            constraints.maxHeight,
            animationProgress
        )
    }
}
 
fun MeasureScope.placeTextAndIcon(
    textPlaceable: Placeable,
    iconPlaceable: Placeable,
    width: Int,
    height: Int,
    @FloatRange(from = 0.0, to = 1.0) animationProgress: Float
): MeasureResult {
 
    // 根據(jù)動(dòng)畫(huà)進(jìn)度值放置文本和圖標(biāo)
    val iconY = (height - iconPlaceable.height) / 2
    val textY = (height - textPlaceable.height) / 2
 
    val textWidth = textPlaceable.width * animationProgress
    val iconX = (width - textWidth - iconPlaceable.width) / 2
    val textX = iconX + iconPlaceable.width
 
    return layout(width, height) {
        iconPlaceable.placeRelative(iconX.toInt(), iconY)
        if (animationProgress != 0f) {
            textPlaceable.placeRelative(textX.toInt(), textY)
        }
    }
}

使用自定義布局的時(shí)機(jī)

希望以上示例能幫助您了解自定義布局的工作方式以及這些布局的應(yīng)用理念。標(biāo)準(zhǔn)布局強(qiáng)大而靈活彰居,但它們也需要適應(yīng)很多用例诚纸。有時(shí),若您知道具體的實(shí)現(xiàn)需求陈惰,使用自定義布局可能更加合適畦徘。

當(dāng)您遇到以下場(chǎng)景時(shí),我們推薦使用自定義布局:

  • 難以通過(guò)標(biāo)準(zhǔn)布局實(shí)現(xiàn)的設(shè)計(jì)抬闯。雖然可以使用足夠多的 Row 和 Column 構(gòu)建大部分界面旧烧,但這種實(shí)現(xiàn)方式有時(shí)難以維護(hù)和升級(jí);

  • 需要非常精確地控制測(cè)量和放置邏輯画髓;

  • 需要實(shí)現(xiàn)布局動(dòng)畫(huà)掘剪。我們正在開(kāi)發(fā)可對(duì)放置進(jìn)行動(dòng)畫(huà)處理的新 API,未來(lái)可能不必自行編寫(xiě)布局就能實(shí)現(xiàn)奈虾;

  • 需要完全控制性能夺谁。下文會(huì)詳細(xì)介紹這一點(diǎn)。

修飾符

至此肉微,我們了解了 Layout 可組合項(xiàng)以及構(gòu)建自定義布局的方式匾鸥。如果您使用 Compose 構(gòu)建過(guò)界面,就會(huì)知道修飾符在布局碉纳、配置尺寸和位置方面發(fā)揮著重要作用勿负。通過(guò)前文的示例可以看到,Layout 可組合項(xiàng)接受修飾符鏈作為參數(shù)劳曹。修飾符會(huì)裝飾它們所附加的元素奴愉,可以在布局自身的測(cè)量和放置操作之前參與測(cè)量和放置。接下來(lái)我們來(lái)看看它的工作原理铁孵。

修飾符分很多不同的類型锭硼,可以影響不同的行為,例如繪制修飾符 (DrawModifier)蜕劝、指針輸入修飾符 (PointerInputModifier) 以及焦點(diǎn)修飾符 (FocusModifier)檀头。本文我們將重點(diǎn)介紹布局修飾符 (LayoutModifier)轰异,該修飾符提供了一個(gè) measure 方法,該方法的作用與 Layout 可組合項(xiàng)基本相同暑始,不同之處在于搭独,它只作用于單個(gè) Measurable 而不是 List<Measurable>,這是因?yàn)樾揎椃膽?yīng)用對(duì)象是單個(gè)項(xiàng)目廊镜。在 measure 方法中牙肝,修飾符可以修改約束或者實(shí)現(xiàn)自定義放置邏輯,就像布局一樣期升。這表示您并不總是需要編寫(xiě)自定義布局,如果只想對(duì)單個(gè)項(xiàng)目執(zhí)行操作互躬,則可以改用修飾符播赁。

以 padding 修飾符為例,該工廠函數(shù)以修飾符鏈為基礎(chǔ)吼渡,創(chuàng)建能夠捕獲所需 padding 值的 PaddingModifier 對(duì)象容为。

fun Modifier.padding(all: Dp) =
    this.then(PaddingModifier(
            start = all,
            top = all,
            end = all,
            bottom = all
        )
    )
 
private class PaddingModifier(
    val start: Dp = 0.dp,
    val top: Dp = 0.dp,
    val end: Dp = 0.dp,
    val bottom: Dp = 0.dp
) : LayoutModifier {
 
override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()
 
        // 按 padding 尺寸收縮外部約束來(lái)修改測(cè)量
        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
 
        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
                // 按所需的 padding 執(zhí)行偏移以放置內(nèi)容
                placeable.placeRelative(start.roundToPx(), top.roundToPx())
        }
    }
}

除了通過(guò)上例中的方式覆寫(xiě) measure 方法實(shí)現(xiàn)測(cè)量,您也可以使用 Modifier.layout寺酪,在無(wú)需創(chuàng)建自定義布局的情況下直接通過(guò)修飾符鏈向任意可組合項(xiàng)添加自定義測(cè)量和放置邏輯坎背,如下所示:

Box(Modifier
            .background(Color.Gray)
            .layout { measurable, constraints ->
                // 通過(guò)修飾符在豎直方向添加 50 像素 padding 的示例
                val padding = 50
                val placeable = measurable.measure(constraints.offset(vertical = -padding))
                layout(placeable.width, placeable.height + padding) {
                    placeable.placeRelative(0, padding)
                }
            }
        ) {
            Box(Modifier.fillMaxSize().background(Color.DarkGray))
        }

雖然 Layout 接受單個(gè) Modifier 參數(shù),該參數(shù)會(huì)建立一個(gè)按順序應(yīng)用的修飾符鏈寄雀。我們通過(guò)示例來(lái)了解它與布局模型的交互方式得滤。我們將分析下圖修飾符的效果及其工作原理:

△ 修飾符鏈的效果示例

首先,我們?yōu)?Box 設(shè)置尺寸并將其繪制出來(lái)盒犹,但這個(gè) Box 放置在了父布局的左上角懂更,我們可以使用 wrapContentSize 修飾符將 Box 居中放置。wrapContentSize 允許內(nèi)容測(cè)量其所需尺寸急膀,然后使用 align 參數(shù)放置內(nèi)容沮协,align 參數(shù)的默認(rèn)值為 Center,因此可以省略這個(gè)參數(shù)卓嫂。但我們發(fā)現(xiàn)慷暂,Box 還是在左上角。這是因?yàn)榇蠖鄶?shù)布局都會(huì)根據(jù)其內(nèi)容自適應(yīng)調(diào)整尺寸晨雳,我們需要讓測(cè)量尺寸占據(jù)整個(gè)空間行瑞,以便讓 Box 在空間內(nèi)居中。因此餐禁,我們?cè)?wrapContentSize 前面添加 fillMaxSize 布局修飾符來(lái)實(shí)現(xiàn)這個(gè)效果蘑辑。

△ 修飾符鏈的應(yīng)用過(guò)程

我們來(lái)看一下這些修飾符是如何實(shí)現(xiàn)此效果的。您可以借助下圖動(dòng)畫(huà)來(lái)輔助理解該過(guò)程:

△ 修飾符鏈的工作原理

假設(shè)這個(gè) Box 要放入最大尺寸為 200300 像素的容器內(nèi)坠宴,容器會(huì)將相應(yīng)的約束傳入修飾符鏈的第一個(gè)修飾符中洋魂。fillMaxSize 實(shí)際上會(huì)創(chuàng)建一組新約束,并設(shè)置最大和最小寬度與高度,使之等于傳入的最大寬度與高度以便填充到最大值副砍,在本例中是 200300 像素衔肢。這些約束沿著修飾符鏈傳遞以測(cè)量下一個(gè)元素,wrapContentSize 修飾符會(huì)接受這些參數(shù)豁翎,它會(huì)創(chuàng)建新的約束來(lái)放寬對(duì)傳入約束的限制角骤,從而讓內(nèi)容測(cè)量其所需尺寸,也就是寬 0-200心剥,高 0-300邦尊。這看起來(lái)只像是對(duì) fillMax 步驟的反操作,但請(qǐng)注意优烧,我們是使用這個(gè)修飾符實(shí)現(xiàn)項(xiàng)目居中的效果蝉揍,而不是重設(shè)項(xiàng)目的尺寸。這些約束沿著修飾符鏈傳遞到 size 修飾符畦娄,該修飾符創(chuàng)建具體尺寸的約束來(lái)測(cè)量項(xiàng)目又沾,指定尺寸應(yīng)該正好是 5050。最后熙卡,這些約束傳遞到 Box 的布局杖刷,它執(zhí)行測(cè)量并將解析得到的尺寸 (5050) 返回到修飾符鏈,size 修飾符因此也將其尺寸解析為 5050驳癌,并據(jù)此創(chuàng)建放置指令滑燃。然后 wrapContent 解析其大小并創(chuàng)建放置指令以居中放置內(nèi)容。因?yàn)?wrapContent 修飾符知道其尺寸為 200300颓鲜,而下一個(gè)元素的尺寸為 50*50不瓶,所以使用居中對(duì)齊創(chuàng)建放置指令,以便將內(nèi)容居中放置灾杰。最后蚊丐,fillMaxSize 解析其尺寸并執(zhí)行放置操作。

修飾符鏈的執(zhí)行方式與布局樹(shù)的工作方式非常相像艳吠,差異在于每個(gè)修飾符只有一個(gè)子節(jié)點(diǎn)麦备,也就是鏈中的下一個(gè)元素。約束會(huì)向下傳遞昭娩,以便后續(xù)元素用其測(cè)量自身尺寸凛篙,然后返回解析得到的尺寸,并創(chuàng)建放置指令栏渺。該示例也說(shuō)明了修飾符順序的重要性呛梆。通過(guò)使用修飾符對(duì)功能進(jìn)行組合,您可以很輕松地將不同的測(cè)量和布局策略組合在一起磕诊。

高級(jí)功能

接下來(lái)將介紹布局模型的一些高級(jí)功能填物,雖然您不一定總是需要這些功能纹腌,但它們能夠幫助您構(gòu)建更高級(jí)的功能。

固有特性測(cè)量 (Intrinsic Measurement)

前文提到過(guò)滞磺,Compose 使用單遍布局系統(tǒng)升薯。這個(gè)說(shuō)法并不完全正確,布局并不總是能通過(guò)單遍操作就得以完成击困,有時(shí)我們也需要了解有關(guān)子節(jié)點(diǎn)尺寸的信息才能最終確定約束涎劈。以彈出式菜單為例。假設(shè)有一個(gè)包含五個(gè)菜單項(xiàng)的 Column阅茶,如下圖所示蛛枚,它的顯示基本上是正常的,但是可以看到脸哀,每個(gè)菜單項(xiàng)的尺寸卻不相同蹦浦。

△ 菜單項(xiàng)的尺寸不相同

我們很容易想到,讓每個(gè)菜單項(xiàng)都占用允許的最大尺寸即可:

△ 每個(gè)菜單項(xiàng)都占有允許的最大尺寸

但這么做也沒(méi)能完全解決問(wèn)題企蹭,因?yàn)椴藛未翱跁?huì)擴(kuò)大到其最大尺寸白筹。有效的解決方法是使用最大固有寬度來(lái)確定尺寸:

△ 使用最大固有寬度來(lái)確定尺寸

這里確定了 Column 會(huì)盡力為每個(gè)子節(jié)點(diǎn)提供所需的空間智末,對(duì) Text 而言谅摄,其寬度是單行渲染全部文本所需的寬度。在確定固有尺寸后系馆,將使用這些值設(shè)置 Column 的尺寸送漠,然后,子節(jié)點(diǎn)就可以填充 Column 的寬度了由蘑。

如果使用最小值而非最大值闽寡,又會(huì)發(fā)生什么呢?

△ 使用最小固有寬度來(lái)確定尺寸

它將確定 Column 會(huì)使用子節(jié)點(diǎn)的最小尺寸尼酿,而 Text 的最小固有寬度是每行一個(gè)詞時(shí)的寬度爷狈。因此,我們最后得到一個(gè)按詞換行的菜單裳擎。如需詳細(xì)了解固有特性測(cè)量涎永,請(qǐng)參閱 Jetpack Compose 中的布局 Codelab 中的 "固有特性" 部分。

ParentData

到目前為止鹿响,我們看到的修飾符都是通用修飾符羡微,也就是說(shuō),它們可以應(yīng)用于任何可組合項(xiàng)惶我。有時(shí)妈倔,您的布局提供的一些行為可能需要從子節(jié)點(diǎn)獲得一些信息,這便要用到ParentDataModifier绸贡。

我們回到前面那個(gè)在父節(jié)點(diǎn)中居中放置藍(lán)色 Box 的示例盯蝴。這一次毅哗,我們將這個(gè) Box 放在另一個(gè) Box 中。Box 中的內(nèi)容在一個(gè)稱為 BoxScope 的接收器作用域內(nèi)排布结洼。BoxScope 定義了只在 Box 內(nèi)可用的修飾符黎做,它提供了一個(gè)名為 Align 的修飾符。這個(gè)修飾符剛好能夠提供我們要應(yīng)用到藍(lán)色 Box 的功能松忍。因此蒸殿,如果我們知道藍(lán)色 Box 位于另一個(gè) Box 內(nèi),就可以改用 Align 修飾符來(lái)定位它鸣峭。

△ 在 BoxScope 中可以改用 Align 修飾符來(lái)定位內(nèi)容

Align 是一個(gè) ParentDataModifier 而不是我們之前看到的那種布局修飾符宏所,因?yàn)樗皇窍蚱涓腹?jié)點(diǎn)傳遞一些信息,所以如果不在 Box 中摊溶,該修飾符便不可用爬骤。它包含的信息將提供給父 Box,以供其設(shè)置子布局莫换。

您也可以為自己的自定義布局編寫(xiě) ParentDataModifier霞玄,從而允許子節(jié)點(diǎn)向父節(jié)點(diǎn)告知一些信息,以供父節(jié)點(diǎn)在布局時(shí)使用拉岁。

對(duì)齊線 (Alignment Lines)

我們可以使用對(duì)齊線根據(jù)布局頂部坷剧、底部或中心以外的標(biāo)準(zhǔn)來(lái)設(shè)置對(duì)齊。最常用的對(duì)齊線是文本基線喊暖。假設(shè)需要實(shí)現(xiàn)這樣一個(gè)設(shè)計(jì):

△ 需要實(shí)現(xiàn)設(shè)計(jì)圖中的圖標(biāo)和文本對(duì)齊

我們很自然就能想到這樣來(lái)實(shí)現(xiàn)它:

Row {
    Icon(modifier = Modifier
        .size(10. dp)
        .align(Alignment.CenterVertically)
    )
    Text(modifier = Modifier
        .padding(start = 8.dp)
        .align(Alignment.CenterVertically)
    )
}

仔細(xì)觀察惫企,會(huì)發(fā)現(xiàn)圖標(biāo)并沒(méi)有像設(shè)計(jì)稿那樣對(duì)齊在文本的基線上。

△ 圖標(biāo)和文本居中對(duì)齊陵叽,

圖標(biāo)底部沒(méi)有落在文本基線上

我們可以通過(guò)以下代碼進(jìn)行修復(fù):

Row {
    Icon(modifier = Modifier
        .size(10. dp)
        .alignBy { it.measuredHeight }
    )
    Text(modifier = Modifier
        .padding(start = 8.dp)
        .alignByBaseline()
    )
}

首先狞尔,對(duì) Text 使用 alignByBaseline 修飾符。而圖標(biāo)既沒(méi)有基線巩掺,也沒(méi)有其他對(duì)齊線偏序,我們可以使用 alignBy 修飾符讓圖標(biāo)對(duì)齊到我們需要的任何位置。在本例中胖替,我們知道圖標(biāo)的底部是對(duì)齊的目標(biāo)位置研儒,因此將圖標(biāo)的底部進(jìn)行對(duì)齊。最終便實(shí)現(xiàn)了期望的效果:

△ 圖標(biāo)底部與文本基線完美對(duì)齊

由于對(duì)齊功能會(huì)穿過(guò)父節(jié)點(diǎn)刊殉,因此殉摔,處理嵌套對(duì)齊時(shí),只需設(shè)置父節(jié)點(diǎn)的對(duì)齊線记焊,它會(huì)從子節(jié)點(diǎn)獲取相應(yīng)的值逸月。如下例所示:

△ 未設(shè)置對(duì)齊的嵌套布局
△ 通過(guò)父節(jié)點(diǎn)設(shè)置對(duì)齊線

您甚至可以在自定義布局中創(chuàng)建自己的自定義對(duì)齊,從而允許其他可組合項(xiàng)對(duì)齊到它遍膜。

BoxWithConstraints

BoxWithConstraints 是一個(gè)功能強(qiáng)大且很實(shí)用的布局碗硬。在組合中瓤湘,我們可以根據(jù)條件使用邏輯和控制流來(lái)選擇要顯示的內(nèi)容,但是恩尾,有時(shí)候可能希望根據(jù)可用空間的大小來(lái)決定布局內(nèi)容弛说。

從前文中我們知道,尺寸信息直到布局階段才可用翰意,也就是說(shuō)木人,這些信息一般無(wú)法在組合階段用來(lái)決定要顯示的內(nèi)容。此時(shí) BoxWithConstraints 便派上用場(chǎng)了冀偶,它與 Box 類似醒第,但它將內(nèi)容的組合推遲到布局階段,此時(shí)布局信息已經(jīng)可用了进鸠。BoxWithConstraints 中的內(nèi)容在接收器作用域內(nèi)排布稠曼,布局階段確定的約束將通過(guò)該作用域公開(kāi)為像素值或 DP 值。


@Composable
fun BoxWithConstraints(
        ...
        content: @Composable BoxWithConstraintsScope.() -> Unit
)
 
// BoxWithConstraintsScope 公開(kāi)布局階段確定的約束
interface BoxWithConstraintsScope : BoxScope {
    val constraints: Constraints
    val minWidth: Dp
    val maxWidth: Dp
    val minHeight: Dp
    val maxHeight: Dp
}

它內(nèi)部的內(nèi)容可以使用這些約束來(lái)選擇要組合的內(nèi)容客年。例如霞幅,根據(jù)最大寬度選擇不同的呈現(xiàn)方式:

@Composable
fun MyApp(...) {
    BoxWithConstraints() { // this: BoxWithConstraintsScope
        when {
            maxWidth < 400.dp -> CompactLayout()
            maxWidth < 800.dp -> MediumLayout()
            else -> LargeLayout()
        }
    }
}

性能

我們介紹了單遍布局模型如何防止在測(cè)量或放置方面花費(fèi)過(guò)多時(shí)間,也演示了布局階段兩個(gè)不同的子階段: 測(cè)量和放置×抗希現(xiàn)在司恳,我們將介紹性能相關(guān)的內(nèi)容。

盡量避免重組

單遍布局模型的設(shè)計(jì)效果是榔至,任何只影響項(xiàng)目的放置而不影響測(cè)量的修改都可以單獨(dú)執(zhí)行抵赢。以 Jetsnack 為例:

△ Jetsnack 應(yīng)用中產(chǎn)品詳情頁(yè)的協(xié)調(diào)滾動(dòng)效果

這個(gè)產(chǎn)品詳情頁(yè)包含協(xié)調(diào)滾動(dòng)效果欺劳,頁(yè)面上的一些元素根據(jù)滾動(dòng)操作進(jìn)行移動(dòng)或縮放唧取。請(qǐng)注意標(biāo)題區(qū)域,這個(gè)區(qū)域會(huì)隨著頁(yè)面內(nèi)容而滾動(dòng)划提,最后固定在屏幕的頂部枫弟。

@Composable
fun SnackDetail(...) {
    Box {
        val scroll = rememberScrollState(0)
        Body(scroll)
        Title(scroll = scroll.value)
        ...
    }
}
 
@Composable
fun Body(scroll: ScrollState) {
    Column(modifier = Modifier.verticalScroll(scroll)) {
        …
    }
}

為了實(shí)現(xiàn)此效果,我們將不同元素作為獨(dú)立的可組合項(xiàng)疊放在一個(gè) Box 中鹏往,提取滾動(dòng)狀態(tài)并將其傳入 Body 組件淡诗。Body 會(huì)使用滾動(dòng)狀態(tài)進(jìn)行設(shè)置以使內(nèi)容能夠垂直滾動(dòng)。在 Title 等其他組件中可以觀察滾動(dòng)位置伊履,而我們的觀察方式會(huì)對(duì)性能產(chǎn)生影響韩容。例如,使用最直接的實(shí)現(xiàn)唐瀑,簡(jiǎn)單地使用滾動(dòng)值對(duì)內(nèi)容進(jìn)行偏移:

@Composable
fun Title(scroll: Int) {
    Column(
        modifier = Modifier.offset(scroll)
    ) {
        …
    }
}

這種方法的問(wèn)題是群凶,滾動(dòng)是一個(gè)可觀察的狀態(tài)值,讀取該值所處的作用域規(guī)定了狀態(tài)發(fā)生變化時(shí) Compose 需要重新執(zhí)行的操作哄辣。在此示例中请梢,我們要讀取組合中的滾動(dòng)偏移值赠尾,然后使用它來(lái)創(chuàng)建偏移修飾符。只要滾動(dòng)偏移值發(fā)生變化毅弧,Title 組件都需要重新組合气嫁,也就需要?jiǎng)?chuàng)建并執(zhí)行新的偏移修飾符。由于滾動(dòng)狀態(tài)是從組合中讀取的够坐,任何更改都會(huì)導(dǎo)致重組寸宵,在重組時(shí),還需要進(jìn)行布局和繪制這兩個(gè)后續(xù)階段元咙。不過(guò)邓馒,我們不是要更改顯示的內(nèi)容,而是更改內(nèi)容的位置蛾坯。我們還可以進(jìn)一步提高效率光酣,通過(guò)修改一下實(shí)現(xiàn),不再接受原始滾動(dòng)位置脉课,而是傳遞一個(gè)可以提供滾動(dòng)位置的函數(shù):

@Composable
fun Title(scrollProvider: () -> Int) {
    Column(
        modifier = Modifier.offset {
            val scroll = scrollProvider()
            val offset = (maxOffset - scroll).coerceAtLeast(minOffset)
            IntOffset(x = 0, y = offset)
        }
    ) {
        …
    }
}

這時(shí)救军,我們可以在不同的時(shí)間只調(diào)用此 Lambda 函數(shù)并讀取滾動(dòng)狀態(tài)。這里使用了 offset 修飾符倘零,它接受能提供偏移值的 Lambda 函數(shù)作為參數(shù)唱遭。這意味著在滾動(dòng)發(fā)生變化時(shí),不需要重新創(chuàng)建修飾符呈驶,只在放置階段才會(huì)讀取滾動(dòng)狀態(tài)的值拷泽。所以,當(dāng)滾動(dòng)狀態(tài)變化時(shí)我們只需要執(zhí)行放置和繪制操作袖瞻,不需要重組或測(cè)量司致,因此能夠提高性能。

再回到底部導(dǎo)航的示例聋迎,它存在同樣的問(wèn)題脂矫,我們可以用相同方法加以修正:

@Composable
fun BottomNavItem(
    icon: @Composable BoxScope.() -> Unit,
    text: @Composable BoxScope.() -> Unit,
    animationProgress: () -> Float
) {
    …
 
    val progress = animationProgress()
 
    val textWidth = textPlaceable.width * progress
    val iconX = (width - textWidth - iconPlaceable.width) / 2
    val textX = iconX + iconPlaceable.width
 
    return layout(width, height) {
        iconPlaceable.placeRelative(iconX.toInt(), iconY)
        if (animationProgress != 0f) {
            textPlaceable.placeRelative(textX.toInt(), textY)
        }
    }
}

我們使用了能提供當(dāng)前動(dòng)畫(huà)進(jìn)度的函數(shù)作為參數(shù),因此不需要重組霉晕,只執(zhí)行布局即可庭再。您需要掌握一個(gè)原則: 只要可組合項(xiàng)或修飾符的參數(shù)可能頻繁發(fā)生更改,都應(yīng)當(dāng)保持謹(jǐn)慎牺堰,因?yàn)檫@種情況可能導(dǎo)致過(guò)度組合拄轻。只有在更改顯示內(nèi)容時(shí),才需要重組伟葫,更改顯示位置或顯示方式則不需要這么做恨搓。

BoxWithConstraints 可以根據(jù)布局執(zhí)行組合,是因?yàn)樗鼤?huì)在布局階段啟動(dòng)子組合扒俯。出于性能考慮奶卓,我們希望盡量避免在布局期間執(zhí)行組合一疯。因此,相較于 BoxWithConstraints夺姑,我們傾向于使用會(huì)根據(jù)尺寸更改的布局墩邀。當(dāng)信息類型隨尺寸更改時(shí)才使用 BoxWithConstraints。

提高布局性能

有時(shí)候盏浙,布局不需要測(cè)量其所有子節(jié)點(diǎn)便可獲知自身大小眉睹。舉個(gè)例子,有如下構(gòu)成的卡片:

△ 布局卡片示例

圖標(biāo)和標(biāo)題構(gòu)成標(biāo)題欄废膘,剩下的是正文竹海。已知圖標(biāo)大小為固定值,標(biāo)題高度與圖標(biāo)高度相同丐黄。測(cè)量卡片時(shí)斋配,就只需要測(cè)量正文,它的約束就是布局高度減去 48 DP灌闺,卡片的高度則為正文的高度加上 48 DP艰争。

△ 測(cè)量過(guò)程只測(cè)量正文尺寸

系統(tǒng)識(shí)別出只測(cè)量了正文,因此它是決定布局尺寸的唯一重要子節(jié)點(diǎn)桂对,圖標(biāo)和文本仍然需要測(cè)量甩卓,但可以在放置過(guò)程中執(zhí)行。

△ 放置過(guò)程測(cè)量圖標(biāo)和文本

假設(shè)標(biāo)題是 "Layout"蕉斜,當(dāng)標(biāo)題發(fā)生變化時(shí)逾柿,系統(tǒng)不必重新執(zhí)行布局的測(cè)量操作,因此不會(huì)重新測(cè)量正文宅此,從而省去不必要的工作机错。

△ 標(biāo)題發(fā)生變化時(shí)不必重新測(cè)量

總結(jié)

在本文中,我們介紹了自定義布局的實(shí)現(xiàn)過(guò)程诽凌,還使用修飾符構(gòu)建和合并布局行為毡熏,進(jìn)一步降低了滿足確切功能需求的難度坦敌。此外侣诵,還介紹了布局系統(tǒng)的一些高級(jí)功能,例如跨嵌套層次結(jié)構(gòu)的自定義對(duì)齊狱窘,為自有布局創(chuàng)建自定義 ParentDataModifier杜顺,支持自動(dòng)從右向左設(shè)置,以及將組合操作推遲到布局信息已知時(shí)蘸炸,等等躬络。我們還了解如何執(zhí)行單遍布局模型,如何跳過(guò)重新測(cè)量以使其只執(zhí)行重新放置操作的方法搭儒,熟練使用這些方法穷当,您將能編寫(xiě)出通過(guò)手勢(shì)進(jìn)行動(dòng)畫(huà)處理的高性能布局邏輯提茁。

對(duì)布局系統(tǒng)的理解能夠幫助您構(gòu)建滿足確切設(shè)計(jì)需求的布局,從而創(chuàng)建用戶喜愛(ài)的優(yōu)秀應(yīng)用馁菜。如需了解更多茴扁,請(qǐng)查閱以下列出的資源:

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市汪疮,隨后出現(xiàn)的幾起案子峭火,更是在濱河造成了極大的恐慌,老刑警劉巖智嚷,帶你破解...
    沈念sama閱讀 218,682評(píng)論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件卖丸,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡盏道,警方通過(guò)查閱死者的電腦和手機(jī)稍浆,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,277評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)猜嘱,“玉大人粹湃,你說(shuō)我怎么就攤上這事∪” “怎么了为鳄?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,083評(píng)論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)腕让。 經(jīng)常有香客問(wèn)我孤钦,道長(zhǎng),這世上最難降的妖魔是什么纯丸? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,763評(píng)論 1 295
  • 正文 為了忘掉前任偏形,我火速辦了婚禮,結(jié)果婚禮上觉鼻,老公的妹妹穿的比我還像新娘俊扭。我一直安慰自己,他們只是感情好坠陈,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,785評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布萨惑。 她就那樣靜靜地躺著,像睡著了一般仇矾。 火紅的嫁衣襯著肌膚如雪庸蔼。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,624評(píng)論 1 305
  • 那天贮匕,我揣著相機(jī)與錄音姐仅,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛掏膏,可吹牛的內(nèi)容都是我干的劳翰。 我是一名探鬼主播,決...
    沈念sama閱讀 40,358評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼馒疹,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼磕道!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起行冰,我...
    開(kāi)封第一講書(shū)人閱讀 39,261評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤溺蕉,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后悼做,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體疯特,經(jīng)...
    沈念sama閱讀 45,722評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,900評(píng)論 3 336
  • 正文 我和宋清朗相戀三年肛走,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了债查。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片州弟。...
    茶點(diǎn)故事閱讀 40,030評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡邮破,死狀恐怖齿兔,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情葫男,我是刑警寧澤抱冷,帶...
    沈念sama閱讀 35,737評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站梢褐,受9級(jí)特大地震影響旺遮,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜盈咳,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,360評(píng)論 3 330
  • 文/蒙蒙 一耿眉、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧鱼响,春花似錦鸣剪、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,941評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至桶癣,卻和暖如春拥褂,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背牙寞。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,057評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人间雀。 一個(gè)月前我還...
    沈念sama閱讀 48,237評(píng)論 3 371
  • 正文 我出身青樓悔详,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親惹挟。 傳聞我的和親對(duì)象是個(gè)殘疾皇子茄螃,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,976評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容