دنبال کردن بهترین روش‌ها

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

استفاده از remember برای به‌حداقل رساندن محاسبات پرهزینه

توابع ترکیب‌پذیر می‌توانند به‌دفعات زیاد اجرا شوند، مثلاً برای هر فریم از یک پویانمایی. به همین دلیل، باید تا جایی که می‌توانید محاسبات کمتری در بدنه composable خود انجام دهید.

یک روش مهم این است که نتایج محاسبات را با remember ذخیره کنید. به‌این‌ترتیب، محاسبه یک‌بار اجرا می‌شود و می‌توانید هر زمان که لازم بود نتایج را واکشی کنید.

برای مثال، در اینجا کدی وجود دارد که فهرست مرتب‌شده‌ای از نام‌ها را نمایش می‌دهد، اما مرتب‌سازی را به روشی بسیار پرهزینه انجام می‌دهد:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

هر بار که ContactsList بازترکیب می‌شود، کل فهرست مخاطبین دوباره مرتب می‌شود، حتی اگر فهرست تغییر نکرده باشد. اگر کاربر فهرست را پیمایش کند، هرگاه ردیف جدیدی ظاهر شود، Composable دوباره ترکیب می‌شود.

برای حل این مشکل، فهرست را خارج از LazyColumn مرتب کنید و فهرست مرتب‌شده را با remember ذخیره کنید:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, comparator) {
        contacts.sortedWith(comparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
            // ...
        }
    }
}

اکنون، فهرست یک‌بار مرتب می‌شود، زمانی که ContactList برای اولین‌بار ترکیب‌بندی می‌شود. اگر مخاطبین یا مقایسه‌گر تغییر کند، فهرست مرتب‌شده دوباره تولید می‌شود. درغیراین‌صورت، composable می‌تواند به استفاده از فهرست مرتب‌شده ذخیره‌شده در حافظه نهان ادامه دهد.

همیشه هر ورودی ارجاع‌شده در بلوک محاسبه remember را به‌عنوان کلید ارسال کنید تا وقتی آن ورودی‌ها تغییر می‌کنند، مقدار ذخیره‌شده در حافظه نهان به‌روز شود. چون remember کلیدها و نتیجه ذخیره‌شده در حافظه نهان را در جدول جایگاه ذخیره می‌کند و کلیدها را بااستفاده از Object.equals() در هر گذر بازترکیب مقایسه می‌کند، فقط زمانی از remember استفاده کنید که محاسبات پرهزینه یا تخصیص‌های سنگین شیء (مانند قالب‌بندی‌های تاریخ، سایه‌زن‌ها، یا مرتب‌سازی فهرست) را در حافظه نهان ذخیره می‌کنید یا زمانی که هویت شیء باید حفظ شود.

هنگام ارسال یک مجموعه بزرگ یا شیء با پیاده‌سازی گران‌قیمت equals() به‌عنوان کلید به remember برای یک محاسبه ارزان، احتیاط کنید، زیرا مقایسه کلید می‌تواند هزینه بیشتری نسبت به اجرای مستقیم محاسبه داشته باشد:

@Composable
fun ContactHeader(contacts: List<Contact>) {
    // Avoid: Comparing the `contacts` list with .equals() on recomposition
    // is more expensive than reading contacts.size or contacts.firstOrNull() directly.
    val firstContactName = remember(contacts) {
        contacts.firstOrNull()?.name.orEmpty()
    }
    Text(firstContactName)
}

استفاده از کلیدهای چیدمان تنبل و انواع محتوا

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

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

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

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

بدون کمک شما، Compose متوجه نمی‌شود که موارد تغییرنکرده فقط در فهرست منتقل شده‌اند. درعوض، Compose فکر می‌کند «مورد ۲» قدیمی حذف شده است و مورد جدیدی برای مورد ۳، مورد ۴، و موارد بعدی ایجاد شده است. نتیجه این است که Compose هر مورد را در فهرست بازترکیب می‌کند، حتی اگر فقط یکی از آن‌ها واقعاً تغییر کرده باشد.

راه حل در اینجا ارائه کلیدهای مورد است. ارائه کلید پایدار برای هر مورد به Compose اجازه می‌دهد از ترکیب‌بندی‌های مجدد غیرضروری اجتناب کند. در این حالت، Compose می‌تواند تشخیص دهد که موردی که اکنون در جایگاه ۳ قرار دارد همان موردی است که قبلاً در جایگاه ۲ قرار داشت. چون هیچ‌کدام از داده‌های آن مورد تغییر نکرده است، Compose مجبور نیست آن را بازترکیب کند.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
            key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

هنگام کار با چیدمان‌های تنبل (LazyColumn،‏ LazyRow،‏ LazyGrid)، دستورالعمل‌های زیر را نیز درنظر داشته باشید:

  • از کلیدهای منحصربه‌فرد و قابل‌ذخیره در Bundle استفاده کنید: کلیدها باید منحصربه‌فرد، قطعی، و قابل‌ذخیره در Android Bundle باشند زیرا چیدمان‌های تنبل از rememberSaveable به‌صورت داخلی استفاده می‌کنند. انواع اولیه مانند Int، Long، یا String را ترجیح دهید و هرگز از نمایه‌های فهرست یا hashCode() به‌عنوان کلید استفاده نکنید.
  • همیشه contentType را برای فهرست‌های ناهمگن مشخص کنید: contentType = { it.type } را به items یا itemsIndexed ارسال کنید تا Compose فقط جایگاه‌های ترکیب‌بندی را بین مواردی که ساختار بصری یکسانی دارند درطول پیمایش مجدداً استفاده کند.
  • از itemsIndexed به‌جای indexOf استفاده کنید: از فراخوانی list.indexOf(item) در بلوک items خودداری کنید، زیرا این کار باعث می‌شود چیدمان به اسکن O(N²) تبدیل شود؛ به‌جای آن از itemsIndexed استفاده کنید.
  • بالا بردن وضعیت مشترک و خواندن CompositionLocal: نمونه‌های مشترک Flow را جمع‌آوری کنید و مقادیر CompositionLocal (مانند LocalContext.current یا LocalDensity.current) را یک‌بار در ترکیب‌پذیر والد بالای چیدمان تنبل بخوانید و مقادیر حل‌شده را به ترکیب‌پذیرهای مورد منتقل کنید.
  • از Row یا Column برای فهرست‌های کوچک و محدود استفاده کنید: برای مجموعه‌های کوچک ثابت یا محدود (مانند ۱ تا ۱۰ مورد)، یک Row یا Column استاندارد با forEach از مجازی‌سازی تنبل و سربار پیش‌واکشی جلوگیری می‌کند.

برای محدود کردن بازترکیب‌ها، از computedStateOf یا derivedStateOf استفاده کنید

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

val listState = rememberLazyListState()

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

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

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

راه حل این است که از derived state (یا computedStateOf در Compose Runtime نسخه ۱.۱۳ و بالاتر) استفاده کنید. حالت مشتق‌شده به شما امکان می‌دهد به Compose بگویید کدام تغییرات حالت باید بازترکیب را راه‌اندازی کند. در این مورد، مشخص کنید که برایتان مهم است وقتی اولین مورد قابل‌مشاهده تغییر می‌کند. وقتی مقدار آن وضعیت تغییر می‌کند، واسط کاربر باید دوباره ترکیب شود، اما اگر کاربر هنوز به‌اندازه کافی پیمایش نکرده باشد تا مورد جدیدی به بالا آورده شود، لازم نیست دوباره ترکیب شود.

val listState = rememberLazyListState()

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

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

رویکرد اشتقاق حالت مناسب را براساس نسخه Compose و هزینه محاسبه انتخاب کنید:

  • remember { computedStateOf { ... } } (Compose Runtime نسخه ۱.۱۳ و بالاتر): محاسبه خود را در هر خواندن بدون ذخیره کردن نتیجه در حافظه نهان دوباره ارزیابی می‌کند، و ترکیب‌بندی را فقط زمانی باطل می‌کند که خروجی محاسبه‌شده تغییر کند. چون از سربار نوشتن-برگشت نامعتبرسازی و دفترداری حافظه پنهان اجتناب می‌کند، remember { computedStateOf { listState.firstVisibleItemIndex > 0 } } را برای بررسی‌های شرایط پایه و مشتقات سبک ناشی از حالت‌های سریعاً درحال تغییر (مانند آفست‌های پیمایش) ترجیح دهید.
  • remember { derivedStateOf { ... } }: نتیجه محاسبات خود را در چندین خواندن ذخیره می‌کند. از derivedStateOf در نسخه‌های Compose پایین‌تر از ۱.۱۳ استفاده کنید، یا زمانی که خود محاسبه ازنظر محاسباتی پرهزینه است و چندین بار درطول یک مرحله ترکیب‌بندی خوانده می‌شود. مقادیر حالت عکس آنی خوانده‌شده در derivedStateOf (مانند listState.firstVisibleItemIndex) را به‌عنوان کلیدهای remember ارسال نکنید، زیرا این کار باعث می‌شود DerivedState در هر تغییر حالت دوباره ایجاد شود و از رد شدن جلوگیری می‌کند.
  • گیرنده‌های Kotlin ساده (get()) و خواندن مستقیم ویژگی: برای عبارت‌های بولی ساده که در آن‌ها کاهش باطل‌سازی لازم نیست، گیرنده سفارشی ساده را ترجیح دهید و مجموعه .size را مستقیماً بخوانید به‌جای اینکه آن را در derivedStateOf قرار دهید. از produceState با snapshotFlow برای اشتقاق‌های حالت همگام استفاده نکنید، زیرا سربار ارسال روال مشترک غیرضروری اضافه می‌کند.

به‌تعویق انداختن خواندن تا حد امکان

وقتی مشکل عملکردی شناسایی شد، به‌تعویق انداختن خواندن وضعیت می‌تواند کمک کند. به‌تعویق انداختن خواندن وضعیت تضمین می‌کند که Compose حداقل کد ممکن را در ترکیب‌بندی مجدد دوباره اجرا می‌کند. برای مثال، اگر واسط کاربر شما وضعیتی دارد که در درخت ترکیب‌پذیر بالا برده شده است و وضعیت را در ترکیب‌پذیر فرزند می‌خوانید، می‌توانید خواندن وضعیت را در تابع لامبدا بپیچید. با این کار، خواندن فقط زمانی انجام می‌شود که واقعاً لازم باشد. برای مرجع، پیاده‌سازی را در برنامه نمونه Jetsnack ببینید. Jetsnack جلوه‌ای شبیه به نوارابزار جمع‌شونده را در صفحه جزئیات خود پیاده‌سازی می‌کند. برای درک اینکه چرا این تکنیک کار می‌کند، پست وبلاگ Jetpack Compose: Debugging Recomposition را ببینید.

برای دستیابی به این جلوه، Title قابل‌ترکیب به افست پیمایش نیاز دارد تا بتواند خودش را بااستفاده از Modifier افست کند. در اینجا نسخه ساده‌شده‌ای از کد Jetsnack قبل‌از انجام بهینه‌سازی آورده شده است:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

وقتی وضعیت پیمایش تغییر می‌کند، Compose نزدیک‌ترین محدوده بازترکیب والد را باطل می‌کند. در این حالت، نزدیک‌ترین محدوده، SnackDetail ترکیب‌شدنی است. توجه داشته باشید که Box یک تابع درون‌خطی است و بنابراین یک محدوده بازترکیب نیست. بنابراین Compose‏ SnackDetail و هر composable درون SnackDetail را بازترکیب می‌کند. اگر کدتان را طوری تغییر دهید که فقط وضعیتی را بخواند که واقعاً از آن استفاده می‌کنید، می‌توانید تعداد عناصری را که باید بازترکیب شوند کاهش دهید.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

پارامتر پیمایش اکنون lambda است. یعنی Title همچنان می‌تواند به وضعیت بالابرده ارجاع دهد، اما مقدار فقط در داخل Title خوانده می‌شود، جایی که واقعاً به آن نیاز است. As a result, when the scroll value changes, the nearest recomposition scope is now the Title composable–Compose no longer needs to recompose the whole Box.

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

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = scrollProvider()) }
    ) {
        // ...
    }
}

قبلاً، کد از Modifier.offset(x: Dp, y: Dp) استفاده می‌کرد که انحراف را به‌عنوان پارامتر می‌گیرد. با جابه‌جایی به نسخه لامبدا اصلاح‌کننده، می‌توانید مطمئن شوید که تابع، وضعیت پیمایش را در فاز چیدمان می‌خواند. درنتیجه، وقتی وضعیت پیمایش تغییر می‌کند، Compose می‌تواند مرحله ترکیب‌بندی را کاملاً نادیده بگیرد و مستقیماً به مرحله چیدمان برود. وقتی متغیرهای State را که مکرراً تغییر می‌کنند به اصلاح‌کننده‌ها ارسال می‌کنید، باید هر زمان که ممکن است از نسخه‌های لامبدا اصلاح‌کننده‌ها استفاده کنید.

در اینجا مثال دیگری از این رویکرد آورده شده است. این کد هنوز بهینه‌سازی نشده است:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(
    Modifier
        .fillMaxSize()
        .background(color)
)

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

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

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
    Modifier
        .fillMaxSize()
        .drawBehind {
            drawRect(color)
        }
)

از نوشتن برعکس اجتناب کنید

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

ترکیب‌پذیر زیر نمونه‌ای از این نوع اشتباه را نشان می‌دهد.

@Composable
fun BadComposable() {
    var count by remember { mutableIntStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read</b>
}

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

با ننوشتن در وضعیت در «ترکیب‌بندی»، می‌توانید به‌طور کامل از نوشتن معکوس جلوگیری کنید. اگر امکان‌پذیر است، همیشه در پاسخ به رویداد و در لامبدا مانند مثال onClick قبلی، در وضعیت بنویسید.

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

منابع بیشتر