قسمت دوم: Lifecycle of composable
تو این پست درباره چرخه عمر فانکشن های composable حرف میزنیم و میبینم که چطور compose تصمیم میگیره تا یه composable رو recompose کنه یا نه.
گفتیم که رابط کاربری مون رو با مجموعه ای از composable ها تعریف میکنیم. وقتی که این composable ها برای اولین بار کشیده میشن رو صفحه، ما میگیم composition اتفاق افتاده. و بعد از composition، اگه state ای تغییر کرد، باید اون composable هایی که state(آرگمان) شون تغییر کرده دوباره اجرا بشن با داده جدید، این دوباره اجرا شدنه بعد از composition رو بهش میگیم recomposition. پس به render شدن اولیه میگیم composition و به update های بعدش میگیم recomposition. همینطور که گفتم، ما دیگه getter و setter نداریم تا internal state ویجت هامون رو تغییر بدیم، بجاش هر وقت که ورودی فانکشن های composable تغییر کنن، کامپوز متوجه میشه و recomposition انجام میشه بصورت خودکار.
ببصورت کلی اینجوریه:
یه فانکشن composable وارد composition میشه و وقتی composition تموم شد، با تغییر پارامتر میتونه هر چندبار recompose بشه.
نکته: قاعدتا دفعه اول فقط composition اتفاق میوفته دیگه و recomposition هنوز اتفاق نیافتاده.
اگه یه composable چندین بار صدا زده بشه، چندین instance هم ازش تو composition ساخته میشه و هر فراخوانی ای، چرخه عمر خودش رو داره. مثلا:
@Composable fun MyComposable() { Column { Text("Hello") Text("World") } }
الان میبینید که فانکشن Text دوبار با پارامتر های مختلف صدا زده شده، پس دو instance ازش ساخته میشه(قرار میگیره). تو عکس هم رنگ های متفاوت نمایانگر instance جداگانه هست.
هر instance ی از یه composable، با call site اش مشخص میشه. call site جاییکه که یه فانکشن composable صدا زده میشه و هر وقت میگیم که composable چندین instance داره، یعنی توی چندین call site صدا زده شده.
کامپوز دقیقا تشخیص میده که کدوم composable فراخوانی شده تو composition و اگه فانکنشن فراخوانی شده باشه تو composition و پارامترش هم تغییری نکرده باشه، دیگه تو recomposition، کامپوز اونو نادیده میگیره(skip میکنه).
به مثال زیر نگاه کنید:
@Composable fun LoginScreen(showError: Boolean) { if (showError) { LoginError() } LoginInput() // This call site affects where LoginInput is placed in Composition } @Composable fun LoginInput() { /* ... */ } @Composable fun LoginError() { /* ... */ }
توی کد بالا، LoginScreen با یک شرط LoginError رو صدا میزنه و همیشه LoginInput هم فراخوانی میشه. هرکدوم از این فانکشن ها call site مربوط به خودشون رو دارن که با استفاده از این compiler کامپوز میتونه اینا رو از هم تشخیص بده.
میبینید که با تغییر state(تغییر مقدار showError)، recomposition اتفاق افتاده و LoginError به درخت اضافه شده. نکته مهم اینه که چون state فانکشنِ LoginInput تغییری نکرده، پس compose میاد اونو نادیده میگیره(skip) و همون instance قبلی خودش رو قرار میده که این عمل باعث افزایش performance میشه.
اگه یه composable چندین بار فراخوانی کنیم، چندین بار هم به composition اضافه میشه. اما وقتی که چندین بار یه composable رو از یه call site یکسان فراخوانی کنیم(مثل توی یه loop)، compiler کامپوز نمیتونه اینا رو از هم تشخیص بده، بنابراین کامپوز از یه مکانیسم دیگه برای تشخیص اینا استفاده میکنه بنام ترتیب اجرایی یا execution order که تو بعضی موارد مشکل ساز میشه.
به مثال زیر نگاه کنید:
@Composable fun MoviesScreen(movies: List<Movie>) { Column { for (movie in movies) { // MovieOverview composables are placed in Composition given its // index position in the for loop MovieOverview(movie) } } }
تو این مثال، چون ما توی یه loop قرار داریم درنتیجه call site توش همه یکسانن پس compose از execution order استفاده میکنه. حالا مثلا اگه یه movie جدید اضافه بشه به پایین لیست، کامپوز میتونه از مابقیِ movie ها دوباره استفاده کنه و دیگه instance جدید برای هرکدوم از اینا نمیسازه:
اما اگه لیست تغییر کنه مثلا ترتیب آیتم ها عوض بشه، یا بعضی هاشون حذف بشن یا اضافه بشن به بالا یا وسط لیست، recomposition باید اتفاق بیفته، و چون مکانیسم execution order، ترتیب براش مهمه و توی همه این موارد(حذف، جابجایی) ترتیب آیتم ها عوض شده درنتیجه از هرکدوم یک instance جدید میسازد تو recomposition و جایی مهم میشه که شما یه side-effect داشته باشین مثلا برین یه تصویر رو از سمت از سرور دریافت کنید و درحالی که دارید این کار رو انجام میدید، recomposition اتفاق بیفته و هنوز اون load تصویر انجام نشده باشه، بارگذاری تصویر هم باید cancel بشه و دوباره از سَر گرفته بشه با این recomposition.
@Composable fun MovieOverview(movie: Movie) { Column { // Side effect explained later in the docs. If MovieOverview // recomposes, while fetching the image is in progress, // it is cancelled and restarted. val image = loadNetworkImage(movie.url) MovieHeader(image) /* ... */ } }
فرایند composition صفحه MoviesScreen رو نشون میده که برای هر کدوم از MovieOverview یه instance جدید ایجاد کرده(مثلا بخاطر عوض شدن ترتیب). Side effect هم دوباره restart میشه.
ولی ما اینو نمیخوایم که برای هرکدوم یه instance جدید بسازه. ما میخوایم مثلا وقتی ترتیب آیتم ها عوض شد، instance هاشون هم تو درخت composition جاشون عوض بشه نه که instance جدید ساخته بشه. برای همین، compose یه راهی رو فراهم میکنه که به runtime میگه یه سری مقادیر رو از قسمت معینی از درخت composition رو با استفاده از کلید(key composable) شناسایی کن.
این key میاد تو یه بلاک کد یه سری مقادیر منحصر به فرد(unique) رو میگیره که هویت هر instance(آیتم) رو مشخص میکنه و وقتی recomposition اتفاق افتاد بدلیل تغییر ترتیب لیست، دیگه نمیاد با استفاده از مکانیسم نین برای هرکدوم یه instance جدید بسازه، بللکه میاد نگاه میکنه میبینه که key ای که تعریف کردیم توش یه سری مشخصات از اینکه قبلا اینا instance هاشون موجود بوده تو composition، فقط ترتیب شون عوض شده، پس نمیاد instance جدید بسازه فقط ترتیب instance هاشون رو تو درخت composition تغییر میده. (امیدوارم فهمیده باشین!) اینجوری:
@Composable fun MoviesScreenWithKey(movies: List<Movie>) { Column { for (movie in movies) { key(movie.id) { // Unique ID for this movie MovieOverview(movie) } } } }
میبینید که با مقادیر یونیت این key، کامپوز instance های قبلی رو تشخیص داده و دوباره ازشون استفاده کرده و side effect هم دیگه از سَر گرفته نمیشه و به اجرا شدنش ادامه میده.
ببعضی از composable ها، توی خودشون key رو دارن مثل LazyColumn برای آیتم هاش:
@Composable fun MoviesScreenLazy(movies: List<Movie>) { LazyColumn { items(movies, key = { movie -> movie.id }) { movie -> MovieOverview(movie) } } }
اگه یه composable توی composition باشه و ورودی اش هم تغییر نکرده باشه و پایدار باشه(stable) میتونه skip بشه توی recomposition.
لغت skip: یعنی compose اونو نادیده بگیره و recompose نشه.
لغت stable: یعنی ورودی ها پایداره یعنی تغییر نکرده، درنتیجه recompose نمیشه.
یک نوع stable با این قرارداد ها مطابقت داره:
1- نتیجه متد equals همیشه یک چیزه برای دو instance، پس compose اینو stable درنظر میگیره.
2- همه انواع public property ها stable اند و قاعدتا اگه تغییری کنن composition متوجه اش میشه.
کامپایلر کامپوز اینا رو بدون گذاشتن @Stable، پایدار یا stable درنظر میگیره:
1- همه primitive تایپ ها: Boolean
, Int
, Long
, Float
, Char و غیره
.
2- رشته ها(Strings)
3- لامبدا ها
4- هرچی که تو MutableState نگهداری بشه stable هستش با اینکه mutable.
اگه یه composable همه ورودی هاش stable باشن و البته تغییری هم نکرده باشن، compose اونو skip میکنه.
اگه یه چیزی stable نیست، ولی میخواین compose باهاش مثل stable ها برخورد کنه از annotation @Stable استفاده کنید:
// Marking the type as stable to favor skipping and smart recompositions. @Stable interface UiState<T : Result<T>> { val value: T? val exception: Throwable? val hasError: Boolean get() = exception != null }
خب اینم از این، امیدوارم اینا رو یادگرفته باشین. اشکالات، ابهامات و نظراتتون رو تو کامنت ها به اشتراک بذارین.
کانال: Android Corner / ایمیل: cornerdroid@gmail.com