مانند بیشتر ابزارهای رابط کاربری، Compose یک فریم را از طریق چند فاز مشخص رندر میکند. برای مثال، سیستم View در اندروید سه فاز اصلی دارد: اندازهگیری، چیدمان و رسم. Compose نیز بسیار مشابه است، اما یک فاز مهم اضافه در ابتدای کار دارد که Composition نامیده میشود.
مستندات Compose مفهوم Composition را در بخشهای Thinking in Compose و State and Jetpack Compose توضیح میدهد.
Compose سه فاز اصلی دارد:
Composition: مشخص میکند چه رابط کاربریای باید نمایش داده شود. Compose توابع Composable را اجرا میکند و یک توصیف از UI شما میسازد.
Layout: مشخص میکند UI در کجا قرار بگیرد. این فاز شامل دو مرحله است: Measurement و Placement. عناصر Layout خودشان و عناصر فرزندشان را در مختصات دوبعدی، برای هر نود در درخت Layout، اندازهگیری و جایگذاری میکنند.
Drawing: مشخص میکند UI چگونه رندر شود. عناصر UI در یک Canvas رسم میشوند که معمولاً صفحهنمایش دستگاه است.
ترتیب این فازها معمولاً همیشه همینگونه است. این ترتیب باعث میشود داده در یک جهت از Composition به Layout و سپس به Drawing جریان پیدا کند تا یک فریم تولید شود. این فرایند با نام جریان داده یکطرفه یا Unidirectional Data Flow نیز شناخته میشود.
البته مواردی مانند BoxWithConstraints، LazyColumn و LazyRow استثناهای قابل توجهی هستند؛ زیرا Composition فرزندان آنها به فاز Layout والد وابسته است.
از نظر مفهومی، هرکدام از این فازها برای هر فریم رخ میدهند. اما برای بهینهسازی عملکرد، Compose از تکرار کاری که با همان ورودیها همان نتیجه قبلی را تولید میکند جلوگیری میکند. اگر Compose بتواند از نتیجه قبلی استفاده کند، اجرای یک تابع Composable را رد میکند. همچنین اگر لازم نباشد، Compose UI کل درخت را دوباره Layout یا Draw نمیکند.
Compose فقط حداقل کاری را انجام میدهد که برای بهروزرسانی UI لازم است. این بهینهسازی ممکن است چون Compose خواندن State را در فازهای مختلف دنبال میکند.
این بخش توضیح میدهد که سه فاز Compose چگونه با جزئیات بیشتری برای Composableها اجرا میشوند.
در فاز Composition، runtime مربوط به Compose توابع Composable را اجرا میکند و یک ساختار درختی خروجی میدهد که UI شما را نمایش میدهد. این درخت UI شامل نودهای Layout است که همه اطلاعات لازم برای فازهای بعدی را در خود دارند.
در فاز Layout، Compose از درخت UI تولیدشده در فاز Composition بهعنوان ورودی استفاده میکند. مجموعه نودهای Layout همه اطلاعات لازم را دارند تا اندازه و موقعیت هر نود را در فضای دوبعدی مشخص کنند.
در فاز Layout، درخت با الگوریتم سهمرحلهای زیر پیمایش میشود:
اندازهگیری فرزندان: یک نود، اگر فرزند داشته باشد، فرزندان خود را اندازهگیری میکند.
تصمیمگیری درباره اندازه خودش: بر اساس این اندازهگیریها، نود اندازه خودش را مشخص میکند.
جایگذاری فرزندان: هر نود فرزند نسبت به موقعیت خود نود والد قرار داده میشود.
در پایان این فاز، هر نود Layout موارد زیر را دارد:
عرض و ارتفاع مشخصشده
مختصات x و y که باید در آن رسم شود
درخت UI بخش قبل را به یاد بیاورید.
برای این درخت، الگوریتم به شکل زیر عمل میکند:
Row فرزندان خود یعنی Image و Column را اندازهگیری میکند.
Image اندازهگیری میشود. چون فرزندی ندارد، اندازه خودش را مشخص میکند و اندازه را به Row گزارش میدهد.
سپس Column اندازهگیری میشود. این نود ابتدا فرزندان خودش، یعنی دو Composable از نوع Text، را اندازهگیری میکند.
اولین Text اندازهگیری میشود. چون فرزندی ندارد، اندازه خودش را مشخص میکند و اندازهاش را به Column گزارش میدهد.
دومین Text نیز اندازهگیری میشود. چون فرزندی ندارد، اندازه خودش را مشخص کرده و آن را به Column گزارش میدهد.
Column از اندازهگیری فرزندان برای تعیین اندازه خودش استفاده میکند. این نود بیشترین عرض فرزندان و مجموع ارتفاع فرزندان را در نظر میگیرد.
Column فرزندانش را نسبت به خودش قرار میدهد و آنها را بهصورت عمودی زیر هم میگذارد.
Row از اندازهگیری فرزندان برای تعیین اندازه خودش استفاده میکند. این نود بیشترین ارتفاع فرزندان و مجموع عرض فرزندان را در نظر میگیرد. سپس فرزندان خود را جایگذاری میکند.
توجه کنید که هر نود فقط یک بار بازدید شد. Runtime مربوط به Compose فقط به یک بار عبور از درخت UI نیاز دارد تا همه نودها را اندازهگیری و جایگذاری کند. این موضوع عملکرد را بهتر میکند.
وقتی تعداد نودها در درخت افزایش پیدا کند، زمان صرفشده برای پیمایش آن بهصورت خطی افزایش مییابد. در مقابل، اگر هر نود چند بار بازدید شود، زمان پیمایش بهصورت نمایی افزایش پیدا میکند.
در فاز Drawing، درخت دوباره از بالا به پایین پیمایش میشود و هر نود به ترتیب خودش را روی صفحه رسم میکند.
ا استفاده از مثال قبلی، محتوای درخت به شکل زیر رسم میشود:
Row هر محتوایی را که ممکن است داشته باشد، مانند رنگ پسزمینه، رسم میکند.
Image خودش را رسم میکند.
Column خودش را رسم میکند.
Text اول و دوم نیز به ترتیب خودشان را رسم میکنند.
وقتی مقدار یک Snapshot State را در یکی از فازهای گفتهشده میخوانید، Compose بهصورت خودکار دنبال میکند که هنگام خواندن آن مقدار در حال انجام چه کاری بوده است. این دنبالکردن باعث میشود وقتی مقدار State تغییر میکند، Compose بتواند همان خواننده را دوباره اجرا کند. این موضوع پایه قابلیت مشاهده State در Compose است.
معمولاً State را با mutableStateOf() ایجاد میکنید و سپس از یکی از دو روش زیر به آن دسترسی پیدا میکنید: دسترسی مستقیم به property به نام value، یا استفاده از Kotlin property delegate.
در این راهنما، منظور از «خواندن State» هر دو روش دسترسی است، چون از نظر نتیجه معادل هستند.
// خواندن State بدون property delegate
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(paddingState.value)
)
// خواندن State با property delegate
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(padding)
)
در پشت صحنه property delegate، توابع getter و setter برای دسترسی و بهروزرسانی مقدار State استفاده میشوند. این توابع فقط زمانی فراخوانی میشوند که property را بهعنوان یک مقدار reference کنید، نه زمانی که ایجاد میشود. به همین دلیل دو روش بالا معادل هستند.
هر بلوک کدی که هنگام تغییر State خواندهشده بتواند دوباره اجرا شود، یک restart scope است. Compose تغییرات مقدار State و restart scopeها را در فازهای مختلف دنبال میکند.
همانطور که گفته شد، Compose سه فاز اصلی دارد و Compose دنبال میکند که چه Stateای در هرکدام از این فازها خوانده شده است. این کار به Compose اجازه میدهد فقط همان فازهای مشخصی را که برای هر عنصر تحت تأثیر UI نیاز به انجام کار دارند، مطلع کند.
نکته: اینکه یک State کجا ایجاد و ذخیره شده، تأثیر زیادی روی فازها ندارد. فقط مهم است که مقدار State چه زمانی و کجا خوانده میشود.
بخشهای زیر هر فاز را توضیح میدهند و نشان میدهند وقتی مقدار State در آن فاز خوانده شود چه اتفاقی میافتد.
خواندن State داخل یک تابع @Composable یا یک بلوک lambda روی فاز Composition و احتمالاً فازهای بعدی اثر میگذارد. وقتی مقدار State تغییر کند، recomposer اجرای دوباره همه توابع Composableای را که مقدار آن State را خواندهاند زمانبندی میکند.
البته runtime ممکن است تصمیم بگیرد بعضی یا همه توابع Composable را در صورتی که ورودیهایشان تغییر نکرده باشد رد کند.
بسته به نتیجه Composition، Compose UI فازهای Layout و Drawing را اجرا میکند. اگر محتوا همان قبلی باقی بماند و اندازه و Layout تغییر نکند، ممکن است این فازها رد شوند.
var padding by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
// State مربوط به padding در فاز Composition خوانده میشود
// چون هنگام ساخت modifier استفاده میشود.
// تغییرات padding باعث Recomposition میشود.
modifier = Modifier.padding(padding)
)
فاز Layout شامل دو مرحله است: Measurement و Placement.
مرحله Measurement مواردی مانند lambda مربوط به measure که به Composable به نام Layout داده میشود و متد MeasureScope.measure مربوط به interface به نام LayoutModifier را اجرا میکند.
مرحله Placement مواردی مانند بلوک placement مربوط به تابع Layout، بلوک lambda مربوط به Modifier.offset { … } و توابع مشابه را اجرا میکند.
خواندن State در هرکدام از این مراحل روی فاز Layout و احتمالاً فاز Drawing اثر میگذارد. وقتی مقدار State تغییر کند، Compose UI فاز Layout را زمانبندی میکند. همچنین اگر اندازه یا موقعیت تغییر کرده باشد، فاز Drawing نیز اجرا میشود.
نکته کلیدی: مرحله Measurement و مرحله Placement restart scopeهای جداگانه دارند. یعنی خواندن State در مرحله Placement قبل از آن، مرحله Measurement را دوباره اجرا نمیکند. با این حال، این دو مرحله اغلب با هم در ارتباط هستند؛ بنابراین خواندن State در مرحله Placement میتواند روی restart scopeهای دیگری که به مرحله Measurement مربوط هستند اثر بگذارد.
var offsetX by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.offset {
// State مربوط به offsetX در مرحله Placement از فاز Layout خوانده میشود
// زمانی که offset محاسبه میشود.
// تغییرات offsetX باعث restart شدن Layout میشود.
IntOffset(offsetX.roundToPx(), 0)
}
)
خواندن State در کدهای مربوط به Drawing روی فاز Drawing اثر میگذارد. مثالهای رایج شامل Canvas()، Modifier.drawBehind و Modifier.drawWithContent هستند.
وقتی مقدار State تغییر کند، Compose UI فقط فاز Draw را اجرا میکند.
var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
// State مربوط به color در فاز Drawing خوانده میشود
// زمانی که canvas رندر میشود.
// تغییرات color باعث restart شدن Drawing میشود.
drawRect(color)
}
چون Compose خواندن State را بهصورت محلی دنبال میکند، میتوانید با خواندن هر State در فاز مناسب، مقدار کاری را که باید انجام شود کاهش دهید.
مثال زیر را در نظر بگیرید. این مثال یک Image() دارد که از modifier به نام offset استفاده میکند تا موقعیت نهایی Layout تصویر را جابهجا کند و هنگام اسکرول کاربر، یک افکت parallax ایجاد شود.
Box {
val listState = rememberLazyListState()
Image(
// ...
// پیادهسازی غیربهینه!
Modifier.offset(
with(LocalDensity.current) {
// خواندن State مربوط به firstVisibleItemScrollOffset در فاز Composition
(listState.firstVisibleItemScrollOffset / 2).toDp()
}
)
)
LazyColumn(state = listState) {
// ...
}
}
این کد کار میکند، اما عملکرد بهینهای ندارد. همانطور که نوشته شده، کد مقدار State مربوط به firstVisibleItemScrollOffset را میخواند و آن را به تابع Modifier.offset(offset: Dp) ارسال میکند.
وقتی کاربر اسکرول میکند، مقدار firstVisibleItemScrollOffset تغییر میکند. همانطور که یاد گرفتید، Compose هر خواندن State را دنبال میکند تا بتواند کدی را که آن را خوانده دوباره اجرا کند. در این مثال، آن کد محتوای Box است.
این یک مثال از خواندن State در فاز Composition است. این کار الزاماً بد نیست؛ در واقع پایه Recomposition همین است و باعث میشود تغییرات داده بتوانند UI جدید تولید کنند.
نکته کلیدی: این مثال غیربهینه است، چون هر رویداد اسکرول باعث میشود کل محتوای Composable دوباره ارزیابی، اندازهگیری، Layout و در نهایت Draw شود. شما در هر اسکرول فاز Compose را فعال میکنید، در حالی که محتوای نمایشدادهشده تغییر نکرده و فقط موقعیت آن تغییر کرده است. میتوانید خواندن State را طوری بهینه کنید که فقط فاز Layout دوباره فعال شود.
نسخه دیگری از modifier مربوط به offset وجود دارد:
Modifier.offset(offset: Density.() -> IntOffset)
این نسخه یک پارامتر lambda میگیرد و offset نهایی از داخل بلوک lambda برگردانده میشود. کد را به شکل زیر بهروزرسانی کنید:
Box {
val listState = rememberLazyListState()
Image(
// ...
Modifier.offset {
// خواندن State مربوط به firstVisibleItemScrollOffset در فاز Layout
IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
}
)
LazyColumn(state = listState) {
// ...
}
}
چرا این روش عملکرد بهتری دارد؟ بلوک lambdaای که به modifier میدهید در فاز Layout فراخوانی میشود؛ دقیقتر بگوییم، در مرحله Placement از فاز Layout. بنابراین State مربوط به firstVisibleItemScrollOffset دیگر در Composition خوانده نمیشود.
چون Compose دنبال میکند که State چه زمانی خوانده شده است، این تغییر باعث میشود اگر مقدار firstVisibleItemScrollOffset تغییر کند، Compose فقط مجبور باشد فازهای Layout و Drawing را دوباره شروع کند.
نکته: ممکن است فکر کنید گرفتن یک پارامتر lambda نسبت به گرفتن یک مقدار مستقیم هزینه اضافه دارد. بله، این هزینه وجود دارد. اما در این مورد، مزیت محدود کردن خواندن State به فاز Layout از این هزینه بیشتر است. مقدار firstVisibleItemScrollOffset هنگام اسکرول در هر فریم تغییر میکند و با به تعویق انداختن خواندن State تا فاز Layout، میتوانید از Recompositionهای متعدد جلوگیری کنید.
البته در بسیاری موارد کاملاً ضروری است که Stateها در فاز Composition خوانده شوند. با این حال، در برخی موارد میتوانید با فیلتر کردن تغییرات State تعداد Recompositionها را کاهش دهید.
این راهنما قبلاً توضیح داد که فازهای Compose همیشه با ترتیب یکسان اجرا میشوند و در همان فریم راهی برای برگشت به فاز قبلی وجود ندارد. اما این موضوع مانع از آن نمیشود که برنامهها در فریمهای مختلف وارد حلقه Composition شوند.
مثال زیر را در نظر بگیرید:
Box {
var imageHeightPx by remember { mutableIntStateOf(0) }
Image(
painter = painterResource(R.drawable.rectangle),
contentDescription = "I'm above the text",
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { size ->
// این کار را انجام ندهید
imageHeightPx = size.height
}
)
Text(
text = "I'm below the image",
modifier = Modifier.padding(
top = with(LocalDensity.current) { imageHeightPx.toDp() }
)
)
}
این مثال یک ستون عمودی را پیادهسازی میکند؛ تصویر در بالا قرار دارد و متن زیر آن میآید. این کد از Modifier.onSizeChanged() استفاده میکند تا اندازه نهایی تصویر را بگیرد و سپس از Modifier.padding() روی متن استفاده میکند تا متن را به سمت پایین جابهجا کند.
تبدیل غیرطبیعی از Px به Dp خودش نشان میدهد که این کد احتمالاً مشکلی دارد.
مشکل این مثال این است که کد در یک فریم به Layout نهایی نمیرسد. این کد به رخ دادن چند فریم وابسته است؛ کاری که باعث انجام کار غیرضروری میشود و باعث میشود UI برای کاربر روی صفحه جابهجا یا پرشدار دیده شود.
در فاز Composition فریم اول، مقدار imageHeightPx در ابتدا برابر ۰ است. در نتیجه، کد برای متن Modifier.padding(top = 0) را فراهم میکند.
در فاز Layout بعدی، callback مربوط به modifier به نام onSizeChanged اجرا میشود و مقدار imageHeightPx را به ارتفاع واقعی تصویر تغییر میدهد. سپس Compose برای فریم بعدی یک Recomposition زمانبندی میکند.
اما در فاز Drawing همان فریم فعلی، متن با padding برابر ۰ رندر میشود، چون مقدار بهروزرسانیشده imageHeightPx هنوز در UI منعکس نشده است.
Compose فریم دوم را شروع میکند، چون مقدار imageHeightPx تغییر کرده است. در فاز Composition این فریم، State داخل بلوک محتوای Box خوانده میشود. اکنون متن paddingای دریافت میکند که دقیقاً با ارتفاع تصویر مطابقت دارد.
در فاز Layout، مقدار imageHeightPx دوباره تنظیم میشود؛ اما چون مقدار آن ثابت مانده، Recomposition دیگری زمانبندی نمیشود.
نکته کلیدی: در نهایت، padding انتخابشده روی متن اعمال میشود، اما صرف کردن یک فریم اضافه برای ارسال مقدار padding از یک فاز به فاز دیگر غیربهینه است و باعث تولید فریمی با محتوای رویهمافتاده میشود.
این مثال ممکن است کمی ساختگی به نظر برسد، اما مراقب این الگوی کلی باشید:
استفاده از Modifier.onSizeChanged()، onGloballyPositioned() یا عملیات Layout مشابه
بهروزرسانی یک State
استفاده از آن State بهعنوان ورودی یک modifier مربوط به Layout مانند padding()، height() یا موارد مشابه
احتمال تکرار این چرخه
راهحل نمونه بالا استفاده از Layout primitive مناسب است. مثال بالا میتواند با Column() پیادهسازی شود. اما ممکن است مثال پیچیدهتری داشته باشید که نیازمند چیزی سفارشی باشد؛ در این حالت باید یک Layout سفارشی بنویسید.
اصل کلی این است که برای چند عنصر UI که باید نسبت به هم اندازهگیری و جایگذاری شوند، یک منبع حقیقت واحد داشته باشید. استفاده از Layout primitive مناسب یا ساختن یک Layout سفارشی باعث میشود کوچکترین والد مشترک بهعنوان منبع حقیقت عمل کند و بتواند رابطه بین چند عنصر را هماهنگ کند. اضافه کردن State پویا این اصل را میشکند.
این محتوا کاملا رایگان توسط تیم کدلپر ترجمه شده و در اختیار شما کاربران عزیز قرار گرفته است، هر گونه کپی برداری برای مقاصد غیر رایگان و بدون ذکر منبع، مورد پیگیری قانونی قرار میگیرد.
ترجمه شده از منبع: منبع مستندات