توی این مقاله، به استناد داکیومنت گوگل، یکسری نکاتی رو لیست میکنم ک توی دنیای کامپوز باید حواسمون بهشون باشه تا پرفورمنس به فنا نره.
بزار با یه مثال پیش بریم:
@Composable fun ContactList( contacts: List<Contact>, comparator: Comparator<Contact>, modifier: Modifier = Modifier ) { LazyColumn(modifier) { // DON’T DO THIS items(contacts.sortedWith(comparator)) { contact -> // ... } } }
توی این سناریو، میخام لیست کانکتهارو بصورت سورت شده نشون بدم. هروقت ب هر دلیلیContactList
نیاز داره ک recompose بشه، دوبارهcontacts
سورت میشه هرچند ک خود این لیست تغییری نکرده باشه. راه حل معمول این مشکل بصورت زیر حل میشه:
@Composable fun ContactList( contacts: List<Contact>, comparator: Comparator<Contact>, modifier: Modifier = Modifier ) { val sortedContacts = remember(contacts, sortComparator) { contacts.sortedWith(sortComparator) } LazyColumn(modifier) { items(sortedContacts) { // ... } } }
استفاده ازremember
اجازه میده ک حین recompositionهای پشت سر هم، الکی متد sort اجرا نشه. درواقع بremember
گفتیم ک فقط زمانی دوباره sort رو اجرا کن کcontacts
یا sortComparator
آپدیت بشن. اما بازم یه داستان داریم: Configuration change !
بالاخره کاربر گوشی رو ممکنه rotate کنه، دارک مود رو فعال کنه، گوشیش رو fold کنه و ... اینجوری مقدارremember
میپره. برای حل این مشکل، قشنگتره ک ازrememberSavable
استفاده کنیم.
وقتی میخایم توی کامپوز یه لیستی از آیتم هارو نشون بدیم از LazyColumn
استفاده می کنیم. خیلی راحت یه LazyColumn
می ندازیم و با items
لیست رو لود میکنیم:
@Composable fun NotesList(notes: List<Note>) { LazyColumn { items( items = notes ) { note -> NoteRow(note) } } }
این راهکاریه ک وقتی گوگل میکنی، ب چشم میخوره. اما توی یکسری حالت ها، recompositionهای اضافه رخ میده:
۱. لیست آیتم ها orderشون تغییر میکنه
۲. آیتمی به لیست اضافه میشه یا ازش حذف میشه
به ازای دو حالت بالا، کل آیتم های لیست recompose میشن، هرچند همون آیتم قبلی باشن.
راه حل: برای این شرایط، دی اس الitems
یه composable قبول میکنه با عنوان key. در واقع این کلید به کامپوز میگه ک چطور هوشمندانه تر recomposition رو مدیریت کنه:
@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) } } }
درواقع میگیم ک: هی کامپوز! هروقت آیدی آیتم تغییر کرد، اونو recompose کن.
اما هنوز یه داستان دیگه وجود داره. اگه آیتم رو آپدیت کنیم چی؟ مثلا متن نوت رو ادیت بزنیم. ازونجایی ک آیدی آیتم آپدیت نشده، آیتم مربوطه recompose نمیشه.
ایدهای ک برای حل این مشکل بذهنم میرسه این هس ک مثلا از timestamp به عنوان آیدی استفاده کنیم. هروقت هم ک هر آیتم آپدیت میشه، آیدیشم آپدیت میکنیم و اینجوری ریکامپوزیشن تریگر میشه براش.
توی بعضی سناریوها ممکنه state با نرخ خیلی زیادی آپدیت شه و recompositionهای اضافهای رو ایجاد کنه. مثلا توی نمایش دادن یه لیست میخایم بدونیم کدوم آیتم اولین آیتم visible توی لیست هس ک اگه اولین آیتم visible نیس، باتن رفتن ب بالا رو visible کنیم:
val listState = rememberLazyListState() LazyColumn(state = listState) { // ... } val showButton = listState.firstVisibleItemIndex > 0 AnimatedVisibility(visible = showButton) { ScrollToTopButton() }
وقتی کاربر کوچیکترین dragایی میکنه،listState
مداوم آپدیت میشه. این قضیه باعت میشه کل لیست مداوم recompose بشه. راه حل: استفاده از derived state
کار drivedStateOf اینکه ب کامپوز بگه به ازای هر تغییر یه state نیاز نیس recomposition بکنی. اصلاح کد بالا میشه:
val listState = rememberLazyListState() LazyColumn(state = listState) { // ... } val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } AnimatedVisibility(visible = showButton) { ScrollToTopButton() }
درواقع اینجا مقدار showButton
زمانی تغییر میکنه ک اون شرط ذکر شده آپدیت شه، نه لزوما به ازای هر آپدیت listState
تا جایی ک میشه، read کردن stateها رو توی درخت یه اسکرین کامپوزی باید ببریم ب leafها. بزا با یه مثال بگم:
@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) ) { // ... } }
این مثالیه از پروژه JetSnack. توی SnackDetail
ب scroll.value
دقت کن. هر بار ک کاربر اسکرول میکنه،کلBox
پرنتاش recompose میشه چون نزدیکترین composableاییه ک میبینه (نزدیکترین ریکامپوزیشتن اسکوپ). در صورتی ک مقدارscroll.value
فقط برای ساختنTitle
مورد نیازه.
راه حل: کافیهscroll.value
رو با یه لامدا پاس بدیم پایین:
@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) ) { // ... } }
الان هر بار ک مقدارscroll.value
عوض میشه، تنهاTitle
هست ک recompose میشه.
نکته جانبی: تکه کد بالارو بازم میشه یکم بهینه کرد. Modifier.offset(x: Dp, y: Dp)
یه نسخه لامدایی هم داره ک بهت تضمین میده، خوندن state رو ببره توی layout phase. ینی این شکلی میشه
@Composable private fun Title(snack: Snack, scrollProvider: () -> Int) { // ... Column( modifier = Modifier .offset { IntOffset(x = 0, y = scrollProvider()) } ) { // ... } }
اینجوری وقتی مقدار state اسکرول تغییر میکنه، composition phase کامل بایپس میشه و مستقیم میپره ب layout phase.
برای این موضوع، یه مثال رایج دیگه هم وجود داره: فرض کن یهBox
داری و میخای رنگ بکگراندش رو بصورت انیمیشن بین دو رنگ جابجا کنی. یه پیاده سازیاش اینجوریه:
// Here, assume animateColorBetween() is a function that swaps between // two colors val color by animateColorBetween(Color.Cyan, Color.Magenta) Box(Modifier.fillMaxSize().background(color))
توی این کد یه مشکلی وجود داره و اونم اینکه چون توی هر فریم state رنگ تغییر میکنه، اونBox
توی هر فریم recompose میشه. در صورتیکهdrawBehind
اگه از استفاده کنیم، خوندن state رنگ، فقط توی draw phase خونده میشه و composition و layout phases کامل بایپس میشه:
val color by animateColorBetween(Color.Cyan, Color.Magenta) Box( Modifier .fillMaxSize() .drawBehind { drawRect(color) } )
الان دیگه هروقت رنگ تغییر کنه، کامپوز مستقیم میره سراغ draw phase.
کامپوز همیشه فرض میکنه هیچوقت بعد از اینکه مقدار یه state رو خوندی، بالافاصله توی composable مقدارش رو آپدیت نمیکنی. اگه اینکار رو بکنی، ینی backwards writes انجام دادی. بزا مثال زیر رو ببینیم:
@Composable fun BadComposable() { var count by remember { mutableStateOf(0) } // Causes recomposition on click Button( = { count++ }, modifier = Modifier.wrapContentSize()) { Text("Recompose") } Text("$count") count++ // Backwards write, writing to state after it has been read }
چرا وقتی مقدار count رو افزایش میدی، بالافاصله recomposition رو تریگر میکنی and so on. میوفتی توی یه لوپ بینهایت و مقدار count همینطور بالا میره. آپدیت شدن مقدار یه state همیشه باید از طریق یه event مث انجام بشه.