کامپوز و best practiceهایی ک همیشه باید یادت بمونه! - پارت یک

توی این مقاله، به استناد داکیومنت گوگل، یکسری نکاتی رو لیست میکنم ک توی دنیای کامپوز باید حواسمون بهشون باشه تا پرفورمنس به فنا نره. اگه مجبوری محاسباتی رو توی ویو انجام بدی، حتما ببرش توی rememberSavable بزار با یه مثال پیش بریم:@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استفاده کنیم.تا جایی ک میتونی از key توی lazy layoutها استفاده کنوقتی میخایم توی کامپوز یه لیستی از آیتم هارو نشون بدیم از 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 با نرخ خیلی زیادی آپدیت میشهتوی بعضی سناریوها ممکنه 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هاتا جایی ک میشه، 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.اجتناب از backwards writesکامپوز همیشه فرض میکنه هیچوقت بعد از اینکه مقدار یه state رو خوندی، بالافاصله توی composable مقدارش رو آپدیت نمیکنی. اگه اینکار رو بکنی، ینی backwards writes انجام دادی. بزا مثال زیر رو ببینیم:@Composable fun BadComposable() { var count by remember { mutableStateOf(0) } // Causes recomposition on click Button( = { count++ }, modifier = Modifier.wrapContentSize()) { Text(&quotRecompose&quot) } Text(&quot$count&quot) count++ // Backwards write, writing to state after it has been read } چرا وقتی مقدار count رو افزایش میدی، بالافاصله recomposition رو تریگر میکنی and so on. میوفتی توی یه لوپ بینهایت و مقدار count همینطور بالا میره. آپدیت شدن مقدار یه state همیشه باید از طریق یه event مث انجام بشه.