مراحل Jetpack Compose

مانند اکثر کیت‌های ابزار واسط کاربر دیگر، Compose یک قاب را ازطریق چندین فاز متمایز پرداز می‌کند. برای مثال، سیستم Android View سه فاز اصلی دارد: اندازه‌گیری، چیدمان، و طراحی. Compose بسیار شبیه است اما یک فاز مهم اضافی به نام ترکیب‌بندی در ابتدا دارد.

اسناد Compose ترکیب‌بندی را در Thinking in Compose و State and Jetpack Compose شرح می‌دهد.

سه مرحله قاب

Compose سه فاز اصلی دارد:

  1. ترکیب‌بندی: چه میانای کاربری نشان داده شود. ‫Compose توابع ترکیب‌پذیر را اجرا می‌کند و شرحی از واسط کاربر شما ایجاد می‌کند.
  2. چیدمان: محل قرار دادن میانای کاربر. این مرحله شامل دو گام است: اندازه‌گیری و جای‌گذاری. عناصر چیدمان خودشان و هر عنصر فرزند را در مختصات دوبعدی، برای هر گره در درخت چیدمان، اندازه‌گیری و جای‌گذاری می‌کنند.
  3. طراحی: نحوه پردازش آن. عناصر میانای کاربر در Canvas (بوم) ترسیم می‌شوند که معمولاً صفحه دستگاه است.
سه مرحله‌ای که Compose داده‌ها را به واسط کاربر تبدیل می‌کند (به‌ترتیب، داده، ترکیب‌بندی، چیدمان، طراحی، واسط کاربر).
شکل ۱. سه مرحله‌ای که Compose داده‌ها را به واسط کاربر تبدیل می‌کند.

ترتیب این فازها معمولاً یکسان است و به داده‌ها اجازه می‌دهد در یک جهت از ترکیب‌بندی به چیدمان و سپس به طراحی جریان یابند تا یک قاب تولید شود (که به آن جریان داده یک‌سویه نیز می‌گویند). ‫BoxWithConstraints،‏ LazyColumn، و LazyRow استثناهای قابل‌توجهی هستند که ترکیب‌بندی فرزندان آن‌ها به فاز چیدمان والد بستگی دارد.

ازنظر مفهومی، هریک از این فازها برای هر فریم اتفاق می‌افتد؛ اما برای بهینه‌سازی عملکرد، Compose از تکرار کاری که نتایج یکسانی را از ورودی‌های یکسان در همه این فازها محاسبه می‌کند اجتناب می‌کند. اگر Compose بتواند از نتیجه قبلی دوباره استفاده کند، اجرای تابع ترکیب‌پذیر را رد می‌کند و اگر لازم نباشد، Compose UI کل درخت را دوباره چیدمان یا ترسیم نمی‌کند. Compose فقط حداقل مقدار کار لازم برای به‌روزرسانی واسط کاربر را انجام می‌دهد. این بهینه‌سازی امکان‌پذیر است زیرا Compose خواندن‌های وضعیت را در فازهای مختلف ردیابی می‌کند.

درک کردن مراحل

این بخش نحوه اجرای سه فاز Compose را برای composable با جزئیات بیشتر شرح می‌دهد.

قطعه موسیقی

در فاز ترکیب‌بندی، زمان اجرای Compose توابع ترکیب‌پذیر را اجرا می‌کند و یک ساختار درختی را که نشان‌دهنده واسط کاربر شما است خروجی می‌دهد. این درخت واسط کاربر از گره‌های چیدمان تشکیل شده است که حاوی همه اطلاعات موردنیاز برای مراحل بعدی است، همان‌طور که در ویدیو زیر نشان داده شده است:

شکل ۲. درختی که نشان‌دهنده واسط کاربر شما است و در مرحله ترکیب‌بندی ایجاد می‌شود.

زیربخشی از کد و درخت واسط کاربری به‌صورت زیر است:

تکه‌کدی با پنج ترکیب‌پذیر و درخت واسط کاربر حاصل، با گره‌های فرزند که از گره‌های والد خود منشعب می‌شوند.
شکل ۳.‏ زیربخشی از درخت UI با کد مربوطه.

در این مثال‌ها، هر تابع ترکیب‌پذیر در کد به یک گره چیدمان در درخت واسط کاربر نگاشت می‌شود. در مثال‌های پیچیده‌تر، ترکیب‌پذیرها می‌توانند حاوی منطق و جریان کنترل باشند و با دریافت حالت‌های مختلف، درخت متفاوتی تولید کنند.

چیدمان

در فاز چیدمان، Compose از درخت واسط کاربر تولیدشده در فاز ترکیب‌بندی به‌عنوان ورودی استفاده می‌کند. مجموعه گره‌های چیدمان حاوی همه اطلاعات لازم برای تصمیم‌گیری درباره اندازه و مکان هر گره در فضای دوبعدی است.

شکل ۴. اندازه‌گیری و جای‌گذاری هر گره چیدمان در درخت واسط کاربر درطول فاز چیدمان.

درطول فاز چیدمان، درخت بااستفاده از الگوریتم سه‌مرحله‌ای زیر پیمایش می‌شود:

  1. اندازه‌گیری فرزندان: گره فرزندان خود را درصورت وجود اندازه‌گیری می‌کند.
  2. تصمیم‌گیری درباره اندازه خود: براساس این اندازه‌گیری‌ها، گره درباره اندازه خود تصمیم می‌گیرد.
  3. قرار دادن فرزندان: هر گره فرزند نسبت به موقعیت خود گره قرار می‌گیرد.

در پایان این فاز، هر گره چیدمان دارای موارد زیر است:

  • عرض و ارتفاع اختصاص‌داده‌شده
  • مختصات x و y که باید در آنجا ترسیم شود

درخت واسط کاربر را از بخش قبلی به‌یاد آورید:

تکه‌کدی با پنج ترکیب‌پذیر و درخت واسط کاربر حاصل، با گره‌های فرزند که از گره‌های والد خود منشعب می‌شوند

برای این درخت، الگوریتم به‌صورت زیر عمل می‌کند:

  1. ‫Row فرزندانش، Image و Column، را اندازه‌گیری می‌کند.
  2. Image اندازه‌گیری می‌شود. این عنصر هیچ فرزندی ندارد، بنابراین اندازه خودش را تعیین می‌کند و اندازه را به Row گزارش می‌دهد.
  3. سپس Column اندازه‌گیری می‌شود. ابتدا فرزندان خود (دو Text ترکیب‌شدنی) را اندازه‌گیری می‌کند.
  4. اولین Text اندازه‌گیری می‌شود. این عنصر هیچ فرزند ندارد، بنابراین اندازه خودش را تعیین می‌کند و اندازه خود را به Column گزارش می‌دهد.
    1. Text دوم اندازه‌گیری می‌شود. این عنصر هیچ فرزندی ندارد، بنابراین اندازه خودش را تعیین می‌کند و آن را به Column گزارش می‌دهد.
  5. Column از اندازه‌های فرزند برای تصمیم‌گیری درباره اندازه خودش استفاده می‌کند. این طرح‌بندی از حداکثر عرض فرزند و مجموع ارتفاع فرزندانش استفاده می‌کند.
  6. ‫Column فرزندانش را نسبت به خودش قرار می‌دهد و آن‌ها را به‌صورت عمودی زیر یکدیگر می‌گذارد.
  7. Row از اندازه‌های فرزند برای تصمیم‌گیری درباره اندازه خودش استفاده می‌کند. این طرح‌بندی از حداکثر ارتفاع فرزند و مجموع عرض‌های فرزندانش استفاده می‌کند. سپس فرزندانش را در آن قرار می‌دهد.

توجه داشته باشید که هر گره فقط یک‌بار بازدید شده است. زمان اجرای Compose برای اندازه‌گیری و قرار دادن همه گره‌ها فقط به یک‌بار عبور از درخت واسط کاربر نیاز دارد که این امر عملکرد را بهبود می‌بخشد. وقتی تعداد گره‌ها در درخت افزایش می‌یابد، زمان صرف‌شده برای پیمایش آن به‌صورت خطی افزایش می‌یابد. درمقابل، اگر هر گره چندین بار بازدید شود، زمان پیمایش به‌صورت نمایی افزایش می‌یابد.

طراحی

در مرحله ترسیم، درخت دوباره از بالا به پایین پیمایش می‌شود و هر گره به‌نوبت خودش را روی صفحه ترسیم می‌کند.

شکل ۵. مرحله ترسیم پیکسل‌ها را روی صفحه ترسیم می‌کند.

بااستفاده از مثال قبلی، محتوای درخت به‌صورت زیر ترسیم می‌شود:

  1. ‫Row هر محتوایی را که ممکن است داشته باشد، مانند رنگ پس‌زمینه، ترسیم می‌کند.
  2. ‫Image خودش را می‌کشد.
  3. ‫Column خودش را می‌کشد.
  4. Text اول و دوم به‌ترتیب خودشان را می‌کشند.

شکل ۶. درخت میانای کاربری و نمایش ترسیم‌شده آن.

خواندن وضعیت

وقتی value یک snapshot state را درطول یکی از فازهای فهرست‌شده در بالا می‌خوانید، Compose به‌طور خودکار آنچه را که هنگام خواندن value انجام می‌داد ردیابی می‌کند. این ردیابی به Compose اجازه می‌دهد تا زمانی که value حالت تغییر می‌کند، خواننده را دوباره اجرا کند و این اساس مشاهده‌پذیری حالت در Compose است.

معمولاً بااستفاده از mutableStateOf() وضعیت ایجاد می‌کنید و سپس ازطریق یکی از دو روش زیر به آن دسترسی پیدا می‌کنید: با دسترسی مستقیم به ویژگی value یا بااستفاده از نماینده ویژگی Kotlin. می‌توانید درباره آن‌ها در State in composables بیشتر بخوانید. برای اهداف این راهنما، «خواندن وضعیت» به هریک از این روش‌های دسترسی معادل اشاره دارد.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

در پشت صحنه نماینده ویژگی، از توابع «getter» و «setter» برای دسترسی و به‌روزرسانی value وضعیت استفاده می‌شود. این توابع گیرنده و تنظیم‌کننده فقط زمانی فراخوانده می‌شوند که به ویژگی به‌عنوان مقدار ارجاع دهید، و نه زمانی که ایجاد می‌شود، به همین دلیل است که دو روشی که قبلاً شرح داده شد معادل هستند.

هر بلوک کد که می‌تواند هنگام تغییر وضعیت خواندن دوباره اجرا شود، محدوده بازراه‌اندازی است. Compose تغییرات وضعیت value را پیگیری می‌کند و دامنه‌ها را در فازهای مختلف بازراه‌اندازی می‌کند.

خواندن وضعیت مرحله‌ای

همان‌طور که قبلاً ذکر شد، سه فاز اصلی در Compose وجود دارد و Compose ردیابی می‌کند که چه وضعیتی در هریک از آن‌ها خوانده می‌شود. این کار به Compose اجازه می‌دهد فقط به فازهای خاصی که باید برای هر عنصر تحت‌تأثیر قرارگرفته در واسط کاربر شما کار انجام دهند اطلاع دهد.

بخش‌های زیر هر فاز را توصیف می‌کنند و توضیح می‌دهند که وقتی یک مقدار حالت در آن خوانده می‌شود چه اتفاقی می‌افتد.

فاز ۱: ترکیب‌بندی

خواندن‌های وضعیت در تابع @Composable یا بلوک لامبدا بر ترکیب‌بندی و احتمالاً فازهای بعدی تأثیر می‌گذارد. وقتی value وضعیت تغییر می‌کند، بازترکیب‌کننده اجرای مجدد همه توابع ترکیب‌پذیری را که آن value وضعیت را می‌خوانند زمان‌بندی می‌کند. توجه داشته باشید که اگر ورودی‌ها تغییر نکرده باشند، زمان اجرا ممکن است تصمیم بگیرد از برخی یا همه توابع ترکیب‌پذیر صرف‌نظر کند. برای اطلاعات بیشتر، به رد شدن درصورت تغییر نکردن ورودی‌ها مراجعه کنید.

بسته به نتیجه ترکیب‌بندی، Compose UI فازهای چیدمان و طراحی را اجرا می‌کند. اگر محتوا یکسان بماند و اندازه و چیدمان تغییر نکند، ممکن است این مراحل را رد کند.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

فاز ۲: چیدمان

مرحله چیدمان شامل دو گام است: اندازه‌گیری و جای‌گذاری. مرحله اندازه‌گیری، لامبدای اندازه‌گیری را که به Layout composable ارسال شده است، اجرا می‌کند، روش MeasureScope.measure از رابط LayoutModifier، ازجمله موارد دیگر. مرحله جای‌گذاری، بلوک جای‌گذاری تابع layout، بلوک لامبدا Modifier.offset { … }، و توابع مشابه را اجرا می‌کند.

خواندن وضعیت درطول هریک از این مراحل بر چیدمان و احتمالاً فاز ترسیم تأثیر می‌گذارد. وقتی value وضعیت تغییر می‌کند، Compose UI فاز چیدمان را زمان‌بندی می‌کند. اگر اندازه یا موقعیت تغییر کرده باشد، مرحله طراحی را نیز اجرا می‌کند.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

مرحله ۳: طراحی

خواندن وضعیت درطول کد طراحی بر فاز طراحی تأثیر می‌گذارد. نمونه‌های رایج شامل Canvas()، Modifier.drawBehind، و Modifier.drawWithContent است. وقتی value وضعیت تغییر می‌کند، Compose UI فقط فاز ترسیم را اجرا می‌کند.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

نموداری که نشان می‌دهد خواندن وضعیت درطول فاز ترسیم فقط باعث می‌شود فاز ترسیم دوباره اجرا شود.

بهینه‌سازی خواندن وضعیت

ازآنجایی‌که Compose ردیابی خواندن وضعیت محلی‌شده را انجام می‌دهد، می‌توانید با خواندن هر وضعیت در فاز مناسب، میزان کار انجام‌شده را به حداقل برسانید.

مثال زیر را درنظر بگیرید. این مثال دارای یک Image() است که از اصلاح‌کننده آفست برای آفست کردن موقعیت چیدمان نهایی خود استفاده می‌کند، که منجر به یک اثر اختلاف منظر با پیمایش کاربر می‌شود.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

این کد کار می‌کند، اما منجر به عملکرد غیربهینه می‌شود. همان‌طور که نوشته شده است، کد value وضعیت firstVisibleItemScrollOffset را می‌خواند و آن را به تابع Modifier.offset(offset: Dp) ارسال می‌کند. وقتی کاربر پیمایش می‌کند، value firstVisibleItemScrollOffset تغییر خواهد کرد. همان‌طور که یاد گرفتید، Compose همه خواندن‌های وضعیت را ردیابی می‌کند تا بتواند کد خواندن را بازراه‌اندازی (مجدداً فراخوانی) کند، که در این مثال محتوای Box است.

این نمونه‌ای از خواندن وضعیت در فاز ترکیب‌بندی است. این لزوماً چیز بدی نیست و درواقع اساس بازترکیب است که به تغییرات داده اجازه می‌دهد واسط کاربر جدیدی را منتشر کند.

نکته کلیدی: این مثال غیربهینه است زیرا هر رویداد پیمایش منجر به ارزیابی مجدد، اندازه‌گیری، چیدمان، و درنهایت ترسیم کل محتوای قابل‌ترکیب می‌شود. با هر پیمایش، فاز Compose را راه‌اندازی می‌کنید، حتی اگر محتوای نشان‌داده‌شده تغییر نکرده باشد و فقط موقعیت آن تغییر کرده باشد. می‌توانید خواندن وضعیت را بهینه‌سازی کنید تا فقط فاز چیدمان را دوباره راه‌اندازی کند.

جبران با لامبدا

نسخه دیگری از اصلاح‌کننده آفست دردسترس است: Modifier.offset(offset: Density.() -> IntOffset).

این نسخه یک پارامتر لامبدا می‌گیرد، که در آن افست حاصل توسط بلوک لامبدا برگردانده می‌شود. برای استفاده از کد، آن را به‌روز کنید:

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

پس چرا این روش کارایی بیشتری دارد؟ بلوک لامبدایی که به اصلاح‌کننده ارائه می‌دهید درطول فاز چیدمان (به‌طور خاص، درطول مرحله جای‌گذاری فاز چیدمان) فراخوانی می‌شود، یعنی حالت firstVisibleItemScrollOffset دیگر درطول ترکیب‌بندی خوانده نمی‌شود. چون Compose زمان خوانده شدن وضعیت را ردیابی می‌کند، این تغییر به این معنی است که اگر value مربوط به firstVisibleItemScrollOffset تغییر کند، Compose فقط باید فازهای چیدمان و طراحی را بازراه‌اندازی کند.

البته، اغلب خواندن حالت‌ها در فاز ترکیب‌بندی کاملاً ضروری است. بااین‌حال، مواردی وجود دارد که می‌توانید تعداد بازترکیب‌ها را با فیلتر کردن تغییرات حالت به حداقل برسانید. برای اطلاعات بیشتر درباره این موضوع، derivedStateOf: تبدیل یک یا چند شیء وضعیت به وضعیت دیگر را ببینید.

حلقه بازترکیب (وابستگی فاز چرخه‌ای)

در این راهنما قبلاً ذکر شده بود که فازهای Compose همیشه به همان ترتیب فراخوانی می‌شوند و هیچ راهی برای بازگشت به عقب در همان قاب وجود ندارد. بااین‌حال، این امر مانع از ورود برنامه‌ها به حلقه‌های ترکیب‌بندی در فریم‌های مختلف نمی‌شود. این مثال را درنظر بگیرید:

Box {
    var imageHeightPx by remember { mutableIntStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                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 نشان می‌دهد که کد مشکل دارد.

مشکل این مثال این است که کد در یک قاب به چیدمان «نهایی» نمی‌رسد. کد به چندین فریم متکی است که اتفاق می‌افتد، که کار غیرضروری انجام می‌دهد و منجر به پریدن میانای کاربر در صفحه برای کاربر می‌شود.

ترکیب‌بندی قاب اول

درطول فاز ترکیب‌بندی قاب اول، imageHeightPx در ابتدا 0 است. درنتیجه، کد نوشتار را با Modifier.padding(top = 0) ارائه می‌دهد. فاز چیدمان بعدی، فراخوانی اصلاح‌کننده onSizeChanged را فراخوانی می‌کند، که imageHeightPx را به ارتفاع واقعی تصویر به‌روز می‌کند. سپس Compose یک ترکیب‌بندی مجدد برای قاب بعدی زمان‌بندی می‌کند. بااین‌حال، درطول فاز ترسیم فعلی، نوشتار با حاشیه داخلی 0 پرداز می‌شود، زیرا مقدار به‌روزشده imageHeightPx هنوز منعکس نشده است.

ترکیب قاب دوم

Compose قاب دوم را که با تغییر مقدار imageHeightPx راه‌اندازی شده است آغاز می‌کند. در فاز ترکیب‌بندی این قاب، وضعیت در بلوک محتوای Box خوانده می‌شود. اکنون نوشتار با فاصله‌گذاری‌ای ارائه می‌شود که دقیقاً با ارتفاع تصویر مطابقت دارد. درطول فاز چیدمان، imageHeightPx دوباره تنظیم می‌شود؛ اما بازترکیب دیگری زمان‌بندی نمی‌شود زیرا مقدار ثابت می‌ماند.

نموداری که یک حلقه بازترکیب را نشان می‌دهد که در آن تغییر اندازه در فاز چیدمان باعث ایجاد بازترکیب می‌شود، که سپس باعث می‌شود چیدمان دوباره اتفاق بیفتد.

این مثال ممکن است ساختگی به‌نظر برسد، اما مراقب این الگوی کلی باشید:

  • ‫Modifier.onSizeChanged()،‏ onGloballyPositioned()، یا برخی عملیات‌های چیدمان دیگر
  • به‌روزرسانی برخی‌از وضعیت‌ها
  • از آن حالت به‌عنوان ورودی برای اصلاح‌کننده چیدمان (padding()، height()، یا مشابه) استفاده کنید
  • احتمالاً تکراری

راه‌حل نمونه قبلی استفاده از عناصر اولیه چیدمان مناسب است. مثال قبلی را می‌توان با Column() پیاده‌سازی کرد، اما ممکن است مثال پیچیده‌تری داشته باشید که به چیزی سفارشی نیاز داشته باشد، که در این صورت باید چیدمان سفارشی بنویسید. برای اطلاعات بیشتر، راهنمای چیدمان‌های سفارشی را ببینید.

اصل کلی در اینجا داشتن یک منبع واحد حقیقت برای چندین عنصر میانای کاربر است که باید نسبت‌به یکدیگر اندازه‌گیری و قرار داده شوند. استفاده از یک طرح‌بندی مناسب یا ایجاد یک طرح‌بندی سفارشی به این معنی است که والد مشترک حداقلی به‌عنوان منبع حقیقت عمل می‌کند که می‌تواند رابطه بین چندین عنصر را هماهنگ کند. معرفی حالت پویا این اصل را نقض می‌کند.

برای اطلاعات بیشتر درباره حلقه‌های بازترکیب و نحوه اجتناب از نوشتن در وضعیت در فازهای مختلف، به نوشتن معکوس در Compose مراجعه کنید.