Modifier.composed เปิดตัวใน Compose 1.0 เพื่อให้คุณเข้าถึง
องค์ประกอบการจัดองค์ประกอบจากตัวแก้ไขได้ ตัวอย่างเช่น กรณีการใช้งานที่สำคัญอย่างหนึ่งคือการสร้าง
ตัวปรับแต่งแบบมีสถานะที่จดจำสถานะภายในและแชร์กับตัวปรับแต่งอื่นๆ
ใน Modifier.composed factory
// ❌ BAD: Using Modifier.composed is no longer recommended fun Modifier.pressScale( pressedScale: Float = 0.95f, onClick: () -> Unit ): Modifier = composed( inspectorInfo = debugInspectorInfo { name = "pressScale" properties["pressedScale"] = pressedScale } ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState( targetValue = if (isPressed) pressedScale else 1f, animationSpec = spring(), label = "pressScale" ) this .graphicsLayer { scaleX = scale scaleY = scale } .clickable( interactionSource = interactionSource, indication = null, onClick = onClick ) }
ใช้ Modifier.Node แทน Modifier.composed เนื่องจาก
Modifier.Node ช่วยปรับปรุงวิธีจัดการสถานะภายในตัวปรับแต่ง Modifier.Node คือออบเจ็กต์ที่มีสถานะและมีอายุการใช้งานยาวนานซึ่งสร้างขึ้นครั้งเดียวต่อ
Modifier.Element ที่ใช้กับ LayoutNode และจะยังคงอยู่หลังจากการจัดองค์ประกอบใหม่
แทนที่จะสร้างขึ้นใหม่ผ่านการจัดองค์ประกอบในทุกๆ รอบ ดูข้อมูลเพิ่มเติมเกี่ยวกับเหตุผลและวิธีที่เราออกแบบ Modifier.Node ได้ที่เจาะลึกตัวแก้ไข Compose
เอกสารนี้อธิบายวิธีย้ายข้อมูลจาก Modifier.composed ไปยัง
Modifier.Node ดูข้อมูลเพิ่มเติมเกี่ยวกับการใช้งาน API นี้โดยทั่วไปได้ที่
ใช้ลักษณะการทำงานของตัวแก้ไขที่กำหนดเองโดยใช้ Modifier.Node
ประโยชน์ด้านประสิทธิภาพของ Modifier.Node
การใช้ Modifier.composed ทำให้เกิดคอขวดด้านประสิทธิภาพพื้นฐานหลายประการ ดังนี้
- ค่าใช้จ่ายในการจัดการสถานะ: การจัดการสถานะในขอบเขตนี้ต้องใช้
rememberการเรียกใช้และออบเจ็กต์สถานะสแนปชอต ซึ่งจะทำให้ตารางช่องโฆษณา มีกลุ่มองค์ประกอบที่ไม่จำเป็นและเพิ่มแรงกดดันด้านหน่วยความจำ - การเข้าถึงวงจรที่มีค่าใช้จ่ายสูง: การเข้าถึงวงจรของตัวแก้ไขต้องใช้เอฟเฟกต์ เช่น
DisposableEffectซึ่งจะเพิ่มงานที่ต้องทำอย่างรวดเร็ว สำหรับกรณีการใช้งานที่ง่ายกว่า - ไม่มีการข้าม: เนื่องจาก Lambda ที่ส่งไปยัง
composedแสดงผลเป็นModifierคอมไพเลอร์ Compose จึงทําเครื่องหมายเป็นข้ามไม่ได้ ซึ่งบังคับให้ เรียกใช้ซ้ำทุกครั้งที่เลย์เอาต์จัดองค์ประกอบใหม่ - การจดจำค่าที่คำนวณแล้วและความเท่ากันที่ใช้งานไม่ได้: เนื่องจากฟังก์ชันส่วนขยายภายนอก
ไม่ใช่
@Composableตัวคอมไพเลอร์จึงจดจำค่าที่คำนวณแล้วของแลมบ์ดาภายในไม่ได้ จึงทำให้เกิดการจัดสรรแลมบ์ดาใหม่ทุกครั้งที่เรียกใช้ การไม่มีการแคชนี้ ทำให้การเปรียบเทียบตัวแก้ไขไม่เท่ากันโดยตรง (equals) เนื่องจากComposedModifierเปรียบเทียบ Lambda โดยการอ้างอิง ดังนั้น Compose จึงถือว่าตัวแก้ไข มีการเปลี่ยนแปลงในทุกเฟรมแม้ว่าพารามิเตอร์จะคงที่ก็ตาม - ไม่มีการส่งต่อการเปลี่ยนแปลงอัจฉริยะ: หากไม่มีการติดตามพารามิเตอร์ที่ประกอบได้ระดับบนสุด คุณจะเปรียบเทียบอินพุตใหม่กับอินพุตก่อนหน้าเพื่อการส่งต่อการเปลี่ยนแปลงอัจฉริยะไม่ได้
โดยรวมแล้ว Modifier.composed รูปแบบ API จะกระตุ้นให้เขียนโค้ดที่มีค่าใช้จ่ายสูง
และป้องกันไม่ให้รันไทม์ของ Compose ใช้การเพิ่มประสิทธิภาพตัวแก้ไขเพิ่มเติม
ขั้นตอนหลักในการย้ายข้อมูล
ตัวอย่างต่อไปนี้แสดงตัวแก้ไขที่กำหนดเองทั่วไปซึ่งใช้กับ
Modifier.composed ดูบริบทเพิ่มเติมได้ที่
ใช้ลักษณะการทำงานของตัวแก้ไขที่กำหนดเองโดยใช้ Modifier.Node
fun Modifier.underline( color: Color, thickness: Dp = 2.dp, animationDurationMillis: Int = 300 ): Modifier = composed { val density = LocalDensity.current val strokePx = with(density) { thickness.toPx() } // Drives how much of the underline is drawn: 0f -> 1f val progress = remember { Animatable(0f) } LaunchedEffect(color, thickness) { progress.snapTo(0f) progress.animateTo( targetValue = 1f, animationSpec = tween(durationMillis = animationDurationMillis) ) } drawBehind { val y = size.height - strokePx / 2 drawLine( color = color, start = Offset(0f, y), end = Offset(size.width * progress.value, y), strokeWidth = strokePx ) } }
สร้าง
Modifier.Nodeที่กำหนดเอง (หรือDelegatingNode) โดยทำดังนี้private class UnderlineNode( private var color: Color, private var thickness: Dp, private var animationDurationMillis: Int ) : Modifier.Node() { fun update(color: Color, thickness: Dp, durationMillis: Int) { } }
ใช้ API เสริมของ
Modifier.Nodeอย่างน้อย 1 รายการ ทั้งนี้ขึ้นอยู่กับสิ่งที่ตัวแก้ไขที่กำหนดเองต้องการ (เช่นPointerInputModifierNodeหากต้องการเข้าถึง API อินพุตของพอยน์เตอร์)private class UnderlineNode( private var color: Color, private var thickness: Dp, private var animationDurationMillis: Int ) : Modifier.Node(), DrawModifierNode { private val progress = Animatable(0f) private var animationJob: Job? = null override fun onAttach() { restartAnimation() } fun update(color: Color, thickness: Dp, durationMillis: Int) { val needsRestart = this.color != color || this.thickness != thickness this.color = color this.thickness = thickness this.animationDurationMillis = durationMillis if (needsRestart) restartAnimation() } private fun restartAnimation() { animationJob?.cancel() animationJob = coroutineScope.launch { progress.snapTo(0f) progress.animateTo(1f, tween(animationDurationMillis)) } } override fun ContentDrawScope.draw() { val strokePx = thickness.toPx() val y = size.height - strokePx / 2 drawLine( color = color, start = Offset(0f, y), end = Offset(size.width * progress.value, y), strokeWidth = strokePx ) drawContent() } }
สร้าง
ModifierNodeElementที่สร้างและอัปเดตโหนดที่กำหนดเองprivate class UnderlineElement( private val color: Color, private val thickness: Dp, private val animationDurationMillis: Int ) : ModifierNodeElement<UnderlineNode>() { override fun create() = UnderlineNode(color, thickness, animationDurationMillis) override fun update(node: UnderlineNode) { node.update(color, thickness, animationDurationMillis) } override fun InspectorInfo.inspectableProperties() { name = "underline" properties["color"] = color properties["thickness"] = thickness properties["animationDurationMillis"] = animationDurationMillis } override fun hashCode(): Int { var result = color.hashCode() result = 31 * result + thickness.hashCode() result = 31 * result + animationDurationMillis.hashCode() return result } override fun equals(other: Any?): Boolean { if (this === other) return true val otherElement = other as? UnderlineElement ?: return false return color == otherElement.color && thickness == otherElement.thickness && animationDurationMillis == otherElement.animationDurationMillis } }
อัปเดตโรงงานตัวแก้ไขให้ชี้ไปที่
ModifierNodeElementfun Modifier.underline( color: Color, thickness: Dp = 2.dp, animationDurationMillis: Int = 300 ): Modifier = this then UnderlineElement(color, thickness, animationDurationMillis)
สูตรการย้ายข้อมูลที่พบบ่อย
สูตรต่อไปนี้แสดงวิธีเปลี่ยนรูปแบบที่พบบ่อยจาก Modifier.composed
เป็นโรงงานตัวแก้ไข Modifier.Node หรือ @Composable
เข้าถึง CompositionLocal
รูปแบบ: อ่าน CompositionLocal เดียว เช่น LocalDensity,
Theme หรือ LocalView
เส้นทางการย้ายข้อมูล: ทำเครื่องหมายตัวแก้ไขด้วย @Composable การใช้ตัวแก้ไข composed กับตัวแก้ไข @Composable
Factory เพื่อเข้าถึง CompositionLocal มีความแตกต่างเชิงความหมาย
โดยเมื่อใช้ @Composable Factory ค่า CompositionLocal จะได้รับการแก้ไขที่เว็บไซต์การเรียกของตัวแก้ไข
Factory หากไม่ต้องการให้เป็นเช่นนี้ ให้ใช้การติดตั้งใช้งาน Modifier.Node
ที่กำหนดเองซึ่งอ่าน CompositionLocal โดยใช้
CompositionLocalConsumerModifierNode
ดูข้อมูลเพิ่มเติมได้ที่ สร้างตัวปรับแต่งที่กำหนดเองโดยใช้ Factory ตัวปรับแต่งที่ Composable
// ❌ BAD: Using Modifier.composed to read a single CompositionLocal fun Modifier.themedContainerBorder(): Modifier = composed { Modifier.border( BorderStroke( width = 2.dp, color = LocalColorScheme.current.primaryColor, ) ) .clipToBounds() }
// ✅ GOOD: If the modifier is @Composable, it should be able to access the locals. @Composable fun Modifier.themedContainerBorder() = this then Modifier.border( BorderStroke( width = 2.dp, color = MyTheme.mainColor, ) ) .clipToBounds()
รูปแบบ: การอ่าน CompositionLocal ที่อาจนำไปใช้กับตัวแก้ไข
ที่ตามมา
เส้นทางการย้ายข้อมูล: สร้าง Modifier.Node ที่กำหนดเองซึ่งใช้ CompositionLocalConsumerModifierNode และรวมความสามารถของตัวแก้ไขทั้งหมด
// ❌ BAD: Using Modifier.composed to read a CompositionLocal then using it in another modifier. fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier = composed { // Reading LocalThemePadding.current.small (CompositionLocal) val extraPadding = LocalThemePadding.current.small Modifier.padding(basePadding + extraPadding) }
// ✅ GOOD: A custom Modifier that combines the capabilities of both (layout and composition local reader) modifiers. fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier = this.then(AdaptivePaddingElement(basePadding)) private data class AdaptivePaddingElement( val basePadding: Dp, ) : ModifierNodeElement<AdaptivePaddingNode>() { override fun create() = AdaptivePaddingNode(basePadding) override fun update(node: AdaptivePaddingNode) { node.basePadding = basePadding } override fun InspectorInfo.inspectableProperties() { name = "adaptiveAccessibilityPadding" properties["basePadding"] = basePadding } } private class AdaptivePaddingNode( var basePadding: Dp, ) : Modifier.Node(), LayoutModifierNode, CompositionLocalConsumerModifierNode { override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { val extraPadding = currentValueOf(LocalThemePadding).small val total = (basePadding + extraPadding).roundToPx() val horizontal = total * 2 val vertical = total * 2 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) { placeable.place(total, total) } } }
เข้าถึงฟังก์ชันที่ประกอบกันได้ซึ่งไม่ใช่เลย์เอาต์
รูปแบบ: ตัวแก้ไขต้องเข้าถึงฟังก์ชันที่มีคำอธิบายประกอบด้วย
@Composable และแสดงผลออบเจ็กต์ (เช่น colorResource หรือ
ScrollableDefaults.flingBehavior)
เส้นทางการย้ายข้อมูล: ใส่คำอธิบายประกอบตัวแก้ไขด้วย @Composable
// ❌ BAD: Using Modifier.composed to access a composable function such as colorResource fun Modifier.niceBackground() = composed { // Reading composable function colorResource val gradientColor1 = colorResource(R.color.my_special_color) background(color = gradientColor1, shape = CircleShape) }
// ✅ GOOD: A modifier can be annotation with @Composable to reference composable functions. @Composable // Modifier can be Composable itself. private fun Modifier.niceBackground(): Modifier { val gradientColor1 = colorResource(R.color.my_special_color) return this.background(color = gradientColor1, shape = CircleShape) }
เข้าถึงขอบเขตของโครูทีน
รูปแบบ: ใช้ Modifier.composed เพื่อเรียกใช้ rememberCoroutineScope เพื่อ
เข้าถึงออบเจ็กต์ coroutineScope สำหรับเปิดใช้โครูทีน
เส้นทางการย้ายข้อมูล: ใช้Modifier.Nodeที่กำหนดเองซึ่งมีพร็อพเพอร์ตี้ coroutineScope
ที่เชื่อมโยงกับวงจรตัวแก้ไข (เช่น rememberCoroutineScope
ภายใน Modifier.composed)
// ❌ BAD: Using Modifier.composed to get access to a coroutine scope. fun Modifier.onClickAsyncComposed(onClick: suspend () -> Unit): Modifier = composed { val scope = rememberCoroutineScope() Modifier.pointerInput(onClick) { detectTapGestures { // Needs a coroutine scope to launch suspend lambda. scope.launch { onClick() } } } }
// ✅ GOOD: A custom Modifier.Node has a scoped (modifier lifecycle) coroutineScope that can be used to launch async work. fun Modifier.onClickAsync(onClick: suspend () -> Unit): Modifier = this.then(OnClickAsyncElement(onClick)) private data class OnClickAsyncElement(val onClick: suspend () -> Unit) : ModifierNodeElement<OnClickAsyncNode>() { override fun create(): OnClickAsyncNode = OnClickAsyncNode(onClick) override fun update(node: OnClickAsyncNode) { node.update(onClick) } override fun InspectorInfo.inspectableProperties() { name = "onClickAsync" properties["onClick"] = onClick } } private class OnClickAsyncNode(private var onClick: suspend () -> Unit) : DelegatingNode(), PointerInputModifierNode { private val pointerInputNode = delegate( SuspendingPointerInputModifierNode { detectTapGestures { // Modifier.Node provides `coroutineScope` directly. coroutineScope.launch { onClick() } } } ) fun update(onClick: suspend () -> Unit) { if (this.onClick != onClick) { this.onClick = onClick pointerInputNode.resetPointerInputHandler() } } override fun onPointerEvent( pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize, ) { pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) } override fun onCancelPointerInput() { pointerInputNode.onCancelPointerInput() } }
จดจำสถานะ
รูปแบบ: การใช้ remember ใน Modifier.composed เพื่อบันทึกสถานะในการ
จัดองค์ประกอบใหม่
เส้นทางการย้ายข้อมูล: Modifier.Node สร้างขึ้นเพื่อเก็บสถานะในลักษณะเดียวกัน
สถานะสามารถเก็บไว้ในอินสแตนซ์ได้เช่นเดียวกับพร็อพเพอร์ตี้คลาสอื่นๆ
ที่มีวงจรที่ชัดเจนกว่า
// ❌ BAD: Using Modifier.composed to make the modifier stateful. fun Modifier.tapCountHighlightComposed(colors: List<Color>): Modifier = composed { // 1. Must use `remember` so `tapCount` isn't reset to 0 on every recomposition var tapCount by remember { mutableIntStateOf(0) } Modifier .pointerInput(colors) { detectTapGestures { tapCount++ } } .drawBehind { drawRect(colors[tapCount % colors.size]) } }
// ✅ GOOD: Modifier.Node is the recommended way of creating stateful modifiers. fun Modifier.tapCountHighlight(colors: List<Color>): Modifier = this then TapCountHighlightElement(colors) private data class TapCountHighlightElement( val colors: List<Color>, ) : ModifierNodeElement<TapCountHighlightNode>() { override fun create() = TapCountHighlightNode(colors) override fun update(node: TapCountHighlightNode) { node.updateColors(colors) } override fun InspectorInfo.inspectableProperties() { name = "tapCountHighlight" properties["colors"] = colors } } private class TapCountHighlightNode( private var colors: List<Color>, ) : DelegatingNode(), DrawModifierNode { private var tapCount = 0 // Stateful modifier, this property will survive recompositions since Modifier.Nodes are held in the modifier tree. private val pointerInputNode = delegate( SuspendingPointerInputModifierNode { detectTapGestures { tapCount++ invalidateDraw() } } ) override fun ContentDrawScope.draw() { drawRect(colors[tapCount % colors.size]) drawContent() } fun updateColors(colors: List<Color>) { this.colors = colors invalidateDraw() } }
ใช้เอฟเฟกต์
รูปแบบ: การใช้เอฟเฟกต์เพื่อดำเนินการที่เชื่อมโยงกับวงจรของคอมโพสิต (เช่น เมื่อModifier.composedเข้าหรือออกจากคอมโพสิต)
เส้นทางการย้ายข้อมูล: Modifier.Node มีการเรียกกลับของวงจรที่ชัดเจนซึ่งใช้เพื่อดำเนินการเดียวกันได้
เช่น โดยทั่วไป LaunchedEffect สามารถแทนที่ด้วยการใช้ coroutineScope ภายในเมธอด Modifier.Node onAttach ได้ดังนี้
// ❌ BAD: Using Modifier.composed to launch/run an effect. fun Modifier.logImpressionComposed( targetId: String, onLog: suspend (targetId: String) -> Unit, ): Modifier = composed { // LaunchedEffect is tied to Composition lifecycle LaunchedEffect(targetId) { onLog(targetId) } this }
// ✅ GOOD: Modifier.Node has lifecycle callbacks (e.g onAttach, onDetach) that can be used to emulate effects behaviors. fun Modifier.logImpression(targetId: String, onLog: suspend (targetId: String) -> Unit): Modifier = this.then(LogImpressionElement(targetId, onLog)) private data class LogImpressionElement( val targetId: String, val onLog: suspend (targetId: String) -> Unit, ) : ModifierNodeElement<LogImpressionNode>() { override fun create(): LogImpressionNode = LogImpressionNode(targetId, onLog) override fun update(node: LogImpressionNode) { node.update(targetId, onLog) } override fun InspectorInfo.inspectableProperties() { name = "logImpression" properties["targetId"] = targetId } } private class LogImpressionNode( var targetId: String, var onLog: suspend (targetId: String) -> Unit, ) : Modifier.Node() { private var job: Job? = null override fun onAttach() { super.onAttach() runEffect() // Uses onAttach to track modifier lifecycle. } fun update(targetId: String, onLog: suspend (targetId: String) -> Unit) { // Re-run the effect if the key (`targetId`) changed if (this.targetId != targetId) { runEffect() } this.targetId = targetId this.onLog = onLog } private fun runEffect() { job?.cancel() job = coroutineScope.launch { onLog(targetId) } } }
คงสถานะภาพเคลื่อนไหว
รูปแบบ: Modifier.composed ใช้ animate*AsState
เส้นทางการย้ายข้อมูล: animate*AsState สามารถแบ่งออกเป็นตัวแก้ไขที่กำหนดเอง
ซึ่งตรวจสอบการเรียกกลับของวงจร และมีสถานะ Animatable ดังนี้
// ❌ BAD: Using Modifier.composed to save an animation state. fun Modifier.fadeInOnHoverComposed(isHovered: Boolean): Modifier = composed { val alpha by animateFloatAsState( targetValue = if (isHovered) 1f else 0.4f, animationSpec = tween(durationMillis = 300), label = "alphaAnimation", ) Modifier.graphicsLayer { this.alpha = alpha } }
// ✅ GOOD: Animation state can be saved in Modifier.Node like other types of stateful implementations. fun Modifier.fadeInOnHover(isHovered: Boolean): Modifier = this.then(FadeInOnHoverElement(isHovered)) private data class FadeInOnHoverElement(val isHovered: Boolean) : ModifierNodeElement<FadeInOnHoverNode>() { override fun create(): FadeInOnHoverNode = FadeInOnHoverNode(isHovered) override fun update(node: FadeInOnHoverNode) { node.update(isHovered) } override fun InspectorInfo.inspectableProperties() { name = "fadeInOnHover" properties["isHovered"] = isHovered } } private class FadeInOnHoverNode(var isHovered: Boolean) : Modifier.Node(), LayoutModifierNode { // 1. Persistent Animatable field on the Node instance private val alphaAnimatable = Animatable(if (isHovered) 1f else 0.4f) override fun onAttach() { super.onAttach() startAnimation(isHovered) } // 2. Trigger animation imperatively when `isHovered` argument changes fun update(isHovered: Boolean) { if (this.isHovered != isHovered) { this.isHovered = isHovered if (isAttached) { startAnimation(isHovered) } } } private fun startAnimation(hovered: Boolean) { val targetAlpha = if (hovered) 1f else 0.4f // Use Node's built-in coroutineScope coroutineScope.launch { alphaAnimatable.animateTo( targetValue = targetAlpha, animationSpec = tween(durationMillis = 300), ) } } override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { val placeable = measurable.measure(constraints) return layout(placeable.width, placeable.height) { // Read current animation value during layout placement layer placeable.placeWithLayer(0, 0) { alpha = alphaAnimatable.value } } } }