I am Android Developer
پرفورمنس در جت پک کامپوز ( بررسی در زمان کامپایل )
در این مقاله به بررسی روش های افزایش پرفورمنس جت پک کامپوز در زمان کامپایل می پردازیم .
ورژن کامپوز مورد بحث در این مقاله 1.3.0 (BOM 2022.10.00) است همچنین تمام report ها در حالت Release بررسی شده اند. (اگر تنها بحث پرفورمنس مد نظر شماست تا بخش inferring class stability از مقاله skip کنید)
همونطور که مطلع هستید کامپوز هم مانند view system قبلی اندروید از یک لایف سایکل مشخص پیروی میکند .
طبق تصویر بالا در لایف سایکل جت پک کامپوز در ابتدا Composition اتفاق می افتد در این مرحله یک گراف که node های آن شامل کامپوننت های کامپوز است به صورت in-memory جنریت می شود که در آینده در رابطه با آن به طور مفصل صحبت می کنیم .
مرحله ی بعدی در لایف سایکل layout است . در این مرحله اندازه کامپوننت ها و محل قرار گیری آن ها در صفحه مشخص می شود (measure).
نهایتا کامپوز وارد فاز Draw می شود که در این مرحله بر اساس گراف و همچنین measure آن ها ویو ها را در ترد Ui ترسیم می کند که بر روی اسکرین قابل مشاهده است . ( on draw)
تا این جای کار با لایف سایکل کامپوز آشنا شده ایم . ما برای افزایش پرفورمنس کامپوز در دو سطح می توانیم عمل کنیم :
- سطح اول جایی است که گراف کامپوننت ها جنریت می شود (Composition) اگر در این سطح بتوانیم به تولید گراف optimize شده کمک کنیم می توانیم پرفورمنس را بالا ببریم .
- سطح دوم بعد از draw اولیه است . بعد از اینکه ویو ترسیم شد هر چه تعداد recomposition ها را پایین تر نگه داریم به پرفورمنس کامپوز کمک کرده ایم .
در سطح دوم به کمک شمارش تعداد recpmposition ها و ایجاد تغییرات در compose هایی که مکررا و بی مورد recompose می شوند می توانیم اثر گذار باشیم . اما سطح دوم مورد بحث ما در این مقاله نیست .
بنابراین در این مقاله ما قصد داریم به کمک کامپایلر کامپوز , گراف کامپوننت ها را بهینه سازی کنیم .
اما قبل از این کار اجازه بدید تا اندکی با under the hood کامپوز بیش تر آشنا بشیم و ببینیم که کامپوز اصلا چه طور کار میکنه:
کامپوز در بکگراند :
وقتی یک فانکشن را به کمک انوتیشن composable@ علامت گذاری می کنید خروجی آن فانکشن به Unit تغییر کرده و مقادیری را به عنوان ورودی آن فانکشن در نظر می گیرد (ورودی ها در composable function ها مهم است چه آن هایی که ما تعریف میکنیم چه آن هایی که کامپایلر تزریق میکند مانند composer). همچنین فانکشن قابلیت restartable پیدا می کند که یک امر مهم در recompostion است .
وظیفه ی composable function ها آن است که پس از اجرا با دریافت مقادیر ورودی node هایی را ایجاد کنند و آن را در قسمتی از گراف کامپوننت ها emit کنند .
در این جا به ذکر یک نکته می پردازیم :
حاصل یک composable function یک یا تعدادی node است که هر وقت به آن احتیاج بود در گراف قرار می گیرد . به همین دلیل است که خروجی این فانکشن ها Unit است . این فانکشن ها برای اینکه بتوانند node را هر زمان که لازم بود به گراف ارسال کنند node را صراحتا return نمی کنند بلکه آن را emit می کنند.
متن بالا را دوباره بخوانید در جایی از آن گفتیم که به اضافه ی آرگومان هایی که ما برای یک composable function معین می کنیم کامپایلر هم مقادیری را به عناون آرگومان برای آن ارسال می کند که از دید ما مخفی است .
یکی از این مقادیر مهم composer است . مثال زیر را در نظر بگیرید :
@Composable
fun NamePlate(name: String, lastname: String) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = name)
Text(text = lastname, style = MaterialTheme.typography.subtitle1)
}
}
کد بالا پس از کامپایل به صورت زیر تبدیل می شود :
fun NamePlate(name: String, lastname: String, $composer: Composer<*>) {
$composer.start(123)
Column(modifier = Modifier.padding(16.dp), $composer) {
Text(
text = name,
$composer
)
Text(
text = lastname,
style = MaterialTheme.typography.subtitle1,
$composer
)
}
$composer.end()
}
همانطور که مشاهده می کنید composer به عنوان ورودی به فانکشن اصلی پاس داده می شود و ابتدا و انتهای composable function را با توابع composer.start(123) و composer.end() مشخص می کند
علت این امر آن است که کامپوز سعی دارد یک read Scope برای خود ایجاد کند تا بتواند state ها را بخواند و در مورد آن ها تصمیم گیری کند . عدد پاس داده شده به تابع start یک عدد رندوم است و توسط کامپایلر انتخاب میشود تا از آن به عنوان identity هر scope استفاده کند .
همانطور که مشاهده می کنید composer در تمامی child compose های موجود در بدنه ی NamePlate نیز پاس داده شده است .
اما سوال مهم اینکه composer اصلا چیه ؟
کامپوزر که به آن Calling Context نیز گفته می شود از بالا ترین تا پایین ترین node در گراف را طی می کند و از میان تمام لول های گراف در دسترس است . کامپایلر به کمک composer مشخص میکند که هر node در کجای گراف در حال کال شدن است. به کمک composer , کامپایلر یک قرارداد محکم را تبیین می کند :
تمامی کامپوزبل فانکشن ها فقط می توانند در کامپوزبل فانکشن های دیگر کال شوند.
یا به عبارت دیگر تمامی کامپوزبل ها نیاز دارند تا context را کال کنند (به composer احتیاج دارند.)
همچنین کامپایلر به کمک composer می تواند از هر composable اطلاعات ضروری مورد نیازش را دریافت کند و آن را آماده ی پراسس کند.
این مفهموم که composable ها صرفا در composable های دیگر کال می شوند بسیار شبیه به همان suspend keyword است که در کروتین نیز بیان می شد . در واقع suspend هم به نوعی همان کاری را با فانکشن می کند که انوتیشن composable@ می کند . مثال زیر را در نظر بگیرید :
suspend fun publishTweet(tweet: Tweet): Post = ...
کد بالا توسط کاتلین کامپایلر به کد زیر تبدیل می شود:
fun publishTweet(tweet: Tweet, callback: Continuation<Post>): Unit
در کد بالا callback از نوع Continuation همان چیزی است که تضمین می کند تمام suspend ها در suspend دیگر کال شوند.
در این موارد می توان بحث های زیادی را مطرح کرد که از هدف این مقاله خارج است . بنابراین به سراغ ادامه ی مطالب می رویم :
ران تایم و کامپایلر در کامپوز:
تا اینجای مقاله مکررا در رابطه با compose compiler صحبت کردیم و ازش اسم بردیم اما کامپایلر و ران تایم در کامپوز چه هستن و چه کار میکنند ؟
کامپوز در بکگراند از دو قسمت تشکیل شده :
1) کامپایلر : کامپایلر کامپوز قسمتی است که سورس کد نوشته شده توسط برنامه نویس را دریافت کرده و پس از بررسی صحت آن , گراف کامپوننت ها را جنریت می کند (در رابطه با کامپایلر به طور مفصل صحبت خواهیم کرد)
2) ران تایم : گراف تولید شده توسط کامپایلر را که به صورت in-memory موجود است به عنوان ورودی به ران تایم داده می شود و ران تایم طبق آن تصمیم میگیرد که با ui چگونه برخورد شود
در نهایت ui روی پلتفرم مشخصی به دستورات ترسیم تبدیل می شود و آماده ی نمایش است .
کامپوز کامپایلر :
واضح است که کامپوز از شیوه ی meta programming برای بسیاری از قسمت های فریمورک استفاده می کند (annotations )
در دنیای jvm برای تعریف یک annotation processor به طور معمول از kapt یا ksp استفاده می شود .
اما در جت پک کامپوز برای این که بتواند به برخی از محدودیت های کامپایلر کاتلین غلبه کند از kapt یا ksp استفاده نشده است بلکه به کمک توسعه ی compose compiler کاستوم توانسته نه تنها محدودیت های کامپایلر کاتلین را دور بزند بلکه با رجیستر شدن در کنار کامپایلر کاتلین بسیاری از اعمال compile time را در کنار kotlin compiler به خوبی انجام دهد . (به طور مثال از محدودیت های کامپایلر کاتلین می توان به الزام کوچک بودن حرف اول نام توابع اشاره کرد که در کامپوز نقض میشود)
این که کامپوز از کامپایلر شخصی خودش استفاده می کند به وضوح توانسته سرعت کار آن را افزایش دهد و همچنین همکاری خوبی را با کامپایلر کاتلین آغاز کند .
ساختار کامپایلر :
کامپوز کامپایلر مانند هر کامپایلر دیگری از دو فاز تشکیل شده است :
فاز front end : در این فاز کلمه به کلمه ی کد توکنایز می شود و به روش های مختلف استاتیک بررسی می شود تا از صحت کد های نوشته شده اطمینان یابد .
به طور مثال در این فاز از کامپایلر کامپوز قسمت های انوتیت شده با composable@ پیدا می شوند
سپس بررسی می کند که آیا ترکیب کد ها با هم مطابق موارد مورد انتظار هست و شرایطی را نقض نمی کند سپس در صورت وجود مشکل ارور ها یا وارنینگ هایی را report می کند . این وارنینگ ها و ارور ها در حین کد زدن برنامه نویس افشا می شوند و برنامه نویس را از خطا ها آگاه می کنند . مثلا فرض کنید از یک تابع composable در یک بلاک غیر کامپوزی استفاده کرده اید در این صورت کامپایلر, در جا برای شما ارور هایی را report می کند .
یا یکی دیگر از کار هایی که در این فاز اتفاق می افتد این است که بررسی می شود آیا کامپوز کامپایلر با ورژن کاتلین تعریف شده در پروژه compatible هست یا خیر ؟ زیرا هر کامپایلر کامپوز تنها با ورژن های خاصی از کاتلین کار میکند.
فاز بکند : در فاز فرانت در صورتی که صحت سورس کد تایید شود فاز بکند مشغول به کار می شود و از روی سورس کد اولیه , کد های ماشین مقصد را تولید می کند .
در کامپایلر کامپوز این همان مرحله ی است که در آن کد ها و boilerplate های مربوط به گراف کامپوننت ها تولید می شود .
نکته ای که در این جا حایز اهمیت است این است که در فاز بکند علاوه بر تبدیل سورس کد اولیه به کد های مورد نیاز پلتفرم , کامپوز کامپایلر می تواند سورس کد را برای اهداف خاصی دستکاری کند .
نحوه ی کار به این صورت است که کامپوز کامپایلر سورس هارا دریافت کرده و از آن ها کد های میانی IR یا intermediate representation می سازد سپس در این جا قابلیت آن را دارد که IR را ویرایش کند و تغییرات مد نظرش را به آن اضافه کند . در نهایت از کد های میانی , کد مورد نیاز پلتفرم را میسازد .
به طور مثال برخی از تغییراتی که کامپایلر روی IR اعمال می کند :
همان composer که در تمامی کامپوزبل ها تزریق می شد .
کد های مورد نیاز ایجاد قابلیت preview و live literal در حالت دیباگ
اطلاق stability به کامپوزبل ها (تمام مواردی که در این مقاله تا کنون مطرح کردیم برای این بود که به این جا برسیم!!)
برخی از کد های boilerplate برای جلوگیری از ردیابی IR
و....
در نهایت کد نهایی تولید شده توسط کامپایلر برای پردازش به ران تایم تحویل داده میشود .
کامپوز ران تایم:
ران تایم وظیفه ی پراسس گراف و اماده سازی گراف برای تبدیل شدن به actual ui را بر عهده دارد .
اما در این جا ما یک نکته از مهندسی نرم افزار بیان می کنیم : همونطور که می دونید کار ها در زمان کامپایل سریع تر از ران تایم انجام میشن . ران تایم باید خط به خط تفسیر کند و بر اساس شرایط محیط اجرا تصمیم گیری های حیاتی را انجام دهد .
اگر بخواهیم یک مثال از این قضیه بیان کنیم میتونیم به تفاوت های Hilt , Koin در ماجرای تزریق وابستگی ها اشاره کنیم (البته که مقایسه این دو به دلیل نحوه عملکردشون در ایجاد وابستگی صحیح نیست)
اما هیلت به دلیل اینکه وابستگی ها رو در زمان کامپایل کد جنریت می کنه سرعت بالا تری در ران تایم داره اما زمان بیلد مارو افزایش می ده , در حالی که Koin به دلیل عملکردش در ران تایم , پروفورمنس پایین تری نسبت به هیلت داره .
بنابراین ما هر چه بتوانیم از وظایف ران تایم را به زمان کامپایل بیاوریم , پرفورمنس را افزایش داده ایم .
جت پک کاموز نیز از این اصل برای افزایش پرفورمنس استفاده می کند .
Inferring class stability concept *
این بخش را با یک جمله کلیدی شروع می کنیم :
کامپایلر کامپوز می تواند مواردی را برای ران تایم تدریس کند.
همانطور که قبل تر مطرح کردیم هر چه از وظایف ران تایم را بتوانیم کم تر کنیم می توانیم سرعت را بهبود ببخشیم . بنابراین در این جا کامپایلر وارد عمل می شود و مواردی مانند stability را در لول کامپایل برای کامپوز ران تایم مشخص میکند.
اکنون به توضیح چند مبحث مهم می پردازیم :
ریکامپوزیشن (restartable - recomposition) :
تمامی composable function ها به طور پیشفرض restartable هستند . به این معنا که می توانند به دفعات اجرا شوند . این امر باعث می شود تا فرایند recomposition که برای تغییر یک node از قبل ایجاد شده به کار می رود معنا پیدا کند.
کامپوزبل فانکشن های readOnly :
همانطور که در بالا اشاره شد تمام کامپوزبل فانکشن ها به صورت پیشفرض restartable هستند و توانایی recomposition دارند . اما ما به کمک انوتیشن NonRestartableComposable@ در بالای composable ها می توانیم قابلیت restart را از فانکشن بگیریم در این صورت فانکشن نمی تواند خودش را recompose کند و سیگنال ریکامپوز را صرفا از والد خود دریافت می کند. در رابطه با کاربرد این نوع از توابع در آینده صحبت خواهیم کرد. (به طور مثال از از این توابع به stringResource میتوان اشاره کرد )
اسمارت ریکامپوزیشن (smart recomposition - skippable) :
در تصویر بالا بلاک counter ریکامپوز می شود اما آیا بلاک های دیگر مانند TopAppBar هم باید ریکامپوز شوند؟ در مورد فرزندان counter چه؟ آیا به ریکامپوز شدن همه ی آن ها احتیاج است؟
مشخصا خیر . در واقع این همان کاری است که smart recomposition انجام می دهد . smart recomposition یکی از مصادیق مهم قدرت در جت پک کامپوز است .
* اسمارت ریکامپوزیشن به این معناست که recomposition در کامپوز هایی که ورودی های آن ها تغییری نداشته و آن ورودی ها stable هستند را skip کرده و نادیده بگیریم .*
کامپوز ران تایم با بررسی 2 شرط بالا تصمیم می گیرد که آیا باید ریکامپوزیشن skip شود یا خیر.
آرگومان های stable و unstable
ما در رابطه با فانکشن های restartable و skippable و readOnly صحبت کردیم . اکنون نوبت آن است تا پیرامون آرگومان های این توابع صحبت کنیم:
ارگومان های stable : این نوع از آرگومان ها در طی فرایند ریکامپوزیشن ران تایم را از تغییر مقدارشان مطلع میکنند در نتیجه ران تایم میتواند تصمیم بگیرد که آیا recompostion انجام شود یا خیر
ارگومان های unstable : از تغییر وضعیتشان چیزی را به ران تایم اطلاع نمی دهند.
پس در این جا به goal مقاله رسیدیم :
کامپایلر به طور خودکار وضعیت توابع کامپوز ما را به لحاظ restartable , skippable و آرگومان هارا به لحاظ stable , unstable بودن مشخص میکند .
توابع به صورت نرمال به صورت restartable skippable مارک می شوند (هم قابلیت recompose شدن دارند و هم قابلیت smart recomposition) و همچنین آرگومان ها به صورت Stable .
اما شرایطی وجود دارد که اگر توسط ما نقض شود کامپایلر , آرگومان را unstable میکند و در نتیجه قابلیت skippable از دست می رود (smart recomposition در ران تایم انجام نمی شود)
در این جا هدف این است تا این موارد را پیدا کرده و آن هارا اصلاح کنیم.
از کامپوز نسخه 1.2.0 فیچر composable metrics به کامپایلر اضافه شد که ما می توانیم به کمک بررسی آن متوجه شویم که کامپایلر در مورد composable function های ما چه تصمیمی گرفته است . هدف ما از این جا به بعد آن است که این ریپورت ها را بررسی کنیم و متوجه شویم چه عواملی می تواند باعث unstable شدن input های ما شود و اینکه چگونه آن ها را اصلاح کنیم .
در ابتدا باید metrics ها را فعال کنیم برای این کار کد زیر را به build.gradle سطح اپ اضافه می کنیم :
kotlinOptions {
//jvmTarget = "17"
// following lines (freeCompilerArgs) to enable compose-metrics
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + project.buildDir.absolutePath + "/compose_metrics"
)
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + project.buildDir.absolutePath + "/compose_metrics"
)
}
در گام بعدی بعد از سینک کردن gradle باید کامند زیر را ران کنیم تا گزارش ها generate شوند :(دو بار کلید CTRL را در محیط اندروید استادیو بزنید تا بتوانید کامند را ران کنید)
./gradlew assembleRelease -PNAME.enableComposeCompilerReports=true
بعد از (p-) به جای NAME اسم اپلیکیشن خود را که در پروژه تعریف کرده اید قرار بدید.
همانطور که مشاهده می کنید این کامند در مود ریلیز بیلد می گیرد . اگر در حالت دیباگ ( assembleDebug)
بیلد بگیریم ممکن است نتایج report ها صحیح نباشد (زیرا مطابق آن چه گفته شد در حالت دیباگ مواردی برای live literal , preview به کد اضافه می شوند که میتواند سبب اشتباه در گزارش ها شوند. اما در حالت ریلیز, R8 کد های اضافه را از سورس کد پاک می کند)
بعد از اتمام بیلد در دایرکتوری بیلد اپلیکیشن پوشه ی compose-metrics ایجاد می شود که شامل گزارش هایی است که به بررسی هر کدام خواهیم پرداخت :
app_release-module.json
این فایل شامل گزارش هایی آماری از تمام کامپوزبل فانکشن های ما است:
{
skippableComposables: 147,
restartableComposables: 156,
readonlyComposables: 0,
totalComposables: 168,
}
البته که اطلاعات موجود در این فایل بیش تر از این مقدار است اما ما مهم ترین آن ها را آورده ایم .
تعداد تمام کامپوزبل فانکشن ها و تعداد موارد skippable , restartable و readOnly در این فایل مشخص است .
یک نکته ی مهم که باید در نظر داشته باشیم آن است که :
اگر تعدادی از کامپوزبل های ما skippable نباشند الزاما به معنای یک نکته منفی نیست . ما حداکثر تلاش خود را برای skippable شدن فانکشن ها می کنیم . اما شاید در مواردی کنترل فانکشن از دست ما خارج باشد.
ممکن است در ریپازیتوری های رسمی گوگل هم مواردی را بیابید که skippable نباشند .
app_release-composables.txt
restartable skippable scheme([androidx.compose.ui.UiComposable]) fun MyScreen(
stable title: String
stable content: String
stable itemCount: Int
)
در این فایل که یک ریپورت بسیار مهم است وضعیت توابع کامپوز مشاهده می شود . با تحلیل این فایل میتوان مشکلات موجود در توابع و آرگومان های کامپوز را پیدا کرد.(اکنون با مقایسه تصویر و کد آورده شده باید بتوانید فانکشن مشکل دار را تشخیص دهید )
app_release-composables.csv
نتایج همان فایل بالا را به صورت طبقه بندی شده و در غالب csv به نمایش می گذارد.
app_release-classes.txt
این فایل وضعیت stable بودن کلاس های پاس داده شده به کامپوزبل فانکشن ها را مشخص میکند.
stable class InherentlyStableClass {
stable val publicStableProperty: String
stable val privateUnstableProperty: String
<runtime stability> = Stable
}
اکنون stability آرگومان ها را مورد بحث قرار می دهیم :
تمام تایپ های اولیه (Int,Boolean,Float, ... ) stable اند.
استرینگ ها نیز stable اند.
مثال زیر رادر نظر بگیرید :
@Composable
fun StableComposeWithPrimitiveType(
title: String,
content: String,
itemCount: Int
)
به دلیل stable بودن تمام آرگومان ها, فانکشن skippable است .
restartable skippable scheme([androidx.compose.ui.UiComposable]) fun
StableComposeWithPrimitiveType(
stable title: String
stable content: String
stable itemCount: Int
)
تمام var ها unstable اند .
مثال زیر را در نظر بگیرید :
data class InherentlyUnstableClass(var text: String)
اگر چه text از نوع String است اما به دلیل آنکه به صورت var تعریف شده است مطلقا به عنوان unstable در نظر گرفته میشود . این دیتا کلاس به دلیل وجود یک آرگومان unstable , unstable تلقی شده و اگر به هر کامپوزبل فانکشنی پاس داده شود آن فانکشن قابلیت skip را از دست میدهد.
unstable class InherentlyStableClass {
unstable var text: String
}
* برای اصلاح این امر کافیه تا به جای var از val استفاده کنید
data class InherentlyStableClass(val text: String)
کالکشن ها (List,Set,Map ...) unstable اند.
@Composable
fun UnstableComposeWithList(
title: String,
content: String,
itemCount: List<Int>
)
در مثال بالا تنها وجود یک آرگومان unstable مانند list برای نقض skippable بودن کل تابع کافیست .
restartable scheme([androidx.compose.ui.UiComposable]) fun UnstableComposeWithList(
stable title: String
stable content: String
unstable itemCount: List<Int>
)
* برای اصلاح این امر کافیه که از Mutable Collection ها مانند (MutableList) به جای List استفاده کنیم .
فلو و (observable ها) unstable اند .
فلو ها در هر شرایطی اگر به عنوان آرگومان در کامپوزبل فانکشن ها استفاده شوند unstable خواهند بود. زیرا هنگامی که یک مقدار جدید را emit می کنند ران تایم را notify نمی کنند .
@Composable
fun UnstableWithFlow(events: Flow<String>) {
LaunchedEffect(Unit) {
events.collect {
// Process the events
}
}
}
restartable fun UnstableWithFlow(
unstable events: Flow<String>
unused unstable <this>: MainActivity
)
* برای اصلاح این امر فقط در شرایطی که مطلقا به آن ها نیاز دارید از flow به عنوان آرگومان استفاده کنید .
لامبدا ها به صورت پیشفرض stable اند.
@Composable
fun stableLambdaScreen(
: (Date) -> Unit = { }
) {}
در مثال بالا علارغم unstable بودن ابجکت date , به دلیل آن که با یک stable پوشانده شده است , آرگومان stable تلقی می شود . (دلیل این امر را در قسمت های بعدی بررسی خواهیم کرد)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun stableLambdaScreen(
stable : Function1<Date, Unit>? = @static { it: Date ->
}
* توجه شود که همچنان شرایطی وجود دارد که ممکن است سبب unstable شدن لامبدا فانکشن ها شود (این شرایط وابسته به ورژن کامپوز ممکن است متفاوت باشند . بنابراین همواره گزارش های کامپایلر را بررسی کنید)
تمام آبجکت هایی که در ماژول های غیر کامپوزی اند unstable تلقی می شوند.
@Composable
fun UnstableWithDateType(date: Date) {
Text(text = date.time.toString())
}
در مثال بالا آرگومان Date به دلیل آنکه از یک ماژول غیر کامپوزی است (java) باعث unstable شدن آرگومان میشود .
restartable scheme([androidx.compose.ui.UiComposable]) fun UnstableWithDateType(
unstable date: Date
unused unstable <this>: MainActivity
)
در پروژه های اندرویدی چنین شرایطی ممکن است به دفعات پیش بیاید (به طور مثال در زمان هایی که مواردی از ماژول data به لایه ی ui نشت می کند) که برای اصلاح آن دو روش بسیار مهم وجود دارد : *
روش اول : استفاده از انوتیشن NonRestartableComposable در بالای Composable Function
@NonRestartableComposable
@Composable
fun StableWithNonRestartable(date: Date) {
Text(text = date.time.toString())
}
scheme([androidx.compose.ui.UiComposable]) fun StableWithNonRestartable(
unstable date: Date
)
با بررسی گزارش بالا متوجه چه تغییری می شوید ؟ آیا مشکل unstable بودن Date حل شده است؟ مشخصا خیر . پس چه چیزی تغییر داشته است ؟
با بررسی گزارش متوجه می شویم که فانکشن دیگر restartable نیست (قابلیت recompose ندارد و ReadOnly است) . در واقع ما صورت مسیله را به صورت کامل پاک کرده ایم . وقتی که فانکشن restartable نباشد skippable بودن یا نبودن معنایی پیدا نمی کند که اکنون بخواهیم پیرامون وضعیت آرگومان بحث کنیم.
وقتی که از انوتیشن NonRestartableComposable استفاده می کنیم کامپایلر boilerplate های مورد نیاز برای recompose شدن فانکشن را جنریت نمی کند بنابراین فانکشن قابلیت recompose شدن خودش را از دست می دهد و سیگنال ریکامپوز را تنها از parent خودش دریافت میکند .
استفاده از این انوتیشن به صورت گسترده توصیه شده نیست و برای کامپوزبل فانکشن هایی مناسب است که کوچک هستند و logic خاصی را هندل نمی کنند بنابراین می توانند قابلیت self invalidate یا self recomposition را به صورت پیشفرض نداشته باشند و تنها از طریق parent ریکامپوز شوند.
بنابراین این روش , در بسیاری از موارد کاربردی نیست و استفاده ی اشتباه از آن می تواند باعث بروز رفتار های ناخواسته شود.
روش دوم : پوشاندن آرگومان unstable با یک wrapper class و مارک کردن آن با انوتیشن های Stable@ یا Immutable@
مثال زیر را در نظر بگیرید :
@Stable
data class StableDateHolder(val date: Date)
@Composable
fun StableWithWrapper(date: StableDateHolder) {
Text(text = date.time.toString())
}
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun StableWithWrapper(
stable date: StableDateHolder
)
در این مثال به جای پاس دادن مستقیم آرگومان Date , آن را با یک data calss پوشانده ایم سپس data class را به عنوان Stable مارک کرده ایم و این data class را به جای Date به عنوان آرگومان پاس دادیم , همانطور که مشاهده میکنید بعد از این تغییرات StableDateHolder به عنوان stable تلقی می شود .
همین کار را به کمک انوتیشن Immutable نیز می توان انجام داد .
@Immutable
data class ImmutableDateHolder(val date: Date)
@Composable
fun StableWithWrapper(date: ImmutableDateHolder) {
Text(text = date.time.toString())
}
restartable skippable scheme([androidx.compose.ui.UiComposable]) fun StableWithWrapper(
stable date: ImmutableDateHolder
unused unstable <this>: MainActivity
)
اما تفاوت این دو انوتیشن چیست و چرا موجب stable شدن آرگومان ها می شوند؟
این دو انوتیشن قول های محکمی (strong promise) برای کامپایلر کامپوز هستند که به کمک آن ها برنامه نویس تضمین میکند که کامپایلر میتواند آرگومان های مارک شده را stable تلقی کند .
انوتیشن Immutable :
تضمین میکنیم که مقدار آرگومان در ران تایم قابل تغییر نیست (immutable است) بنابراین ران تایم اصلا احتیاجی به بررسی تغیررات آرگومان ندارد.
انوتیشن Stable:
تضمین میکنیم که با اینکه مقدار آرگومان در زمان ران تایم می تواند عوض شود , در صورت عوض شدن مقدار, حتما ران تایم را از تغییر اتفاق افتاده notify میکنیم . بنابراین ران تایم همواره از تغییرات آگاه هست .
بنابراین هر دوی این انوتیشن های سبب می شوند تا آرگومان stable تلقی شود با این تفاوت که Immutable در مواردی استفاده می شود که مطمین هستیم مقدار آرگومان در ران تایم عوض نمی شود در حالی که در Stable ممکن است مقدار تغییر کند .
اما یک نکته : اگر از این انوتیشن ها به صورت اشتباه استفاده شود چه اتفاقی می افتد ؟ مثال زیر را در نظر بگیرید :
@Immutable
data class ImmutableDateHolder(val date: MutableState<Date>)
@Composable
fun StableWithImmutableWrapData(date: ImmutableDateHolder, : () -> Unit) {
Text(
text = date.date.value.time.toTimeDateString(),
modifier = Modifier.clickable { () })
}
کلاس ImmutableDateHolder به صورت Immutable مارک شده است اما مقدار Date از نوع MutableState مشخص شده است تا بتوان مقدار آن را در ران تایم با کلیک روی Text عوض کرد .
همانطور که قبلا گفته شد در مواردی که از Immutable@ استفاده میشود مقدار آرگومان نباید در ران تایم عوض شود اما در این مثال که این شرایط نقض شده است پس از اجرا چه اتفاقی می افتد؟
هیچ اتفاقی نمی افتد !! برنامه مطابق انتظار اجرا می شود .
طبق گفته ی داکیومنت کامپوز استفاده ی نادرست از این انوتیشن ها ممکن است باعث رخ دادن رفتار های غیر منتظره شود .
در مثال ما ui به صورت صحیح رفتار می کند اما ممکن است در سناریو های پیچیده تر منجر به مشکل شود بنابراین از این انوتیشن ها به صورت صحیح استفاده کنید .
نکته :
این انوتیشن ها یک آرگومان unstable را به ذات stable نمی کنند (Date هنوز هم unstable است) بلکه صرفا قرار داد ما با کامپایلر را تغییر می دهند . هنگام استفاده از این انوتیشن ها, ما به عنوان توسعه دهنده مواردی را برای کامپایلر تضمین میکنیم (promise می دهیم) بنابراین در استفاده از آن ها باید مراقب بود.
نکته مهم 1 : همه ی composable ها را الزاما نباید skippable کرد.
گاهی از اوقات تلاش برای skippable کردن همه ی composable ها خود می تواند باعث ایجاد مشکلات performance شود و یا باعث شود تا کد دیگر maintainable نباشد و یا هزینه زیادی را در زمان توسعه به پروژه اعمال کند.
مثال زیر را در نظر بگیرید :
@Composable
inline fun UnstableWithInline() {}
اگر یک composable function را به صورت inline مارک کنیم باعث میشود تا composable در کامپایلر به صورت inline تلقی شود و قابلیت skip را از دست بدهد
در این شرایط حتی کامپایلر نسبت به مشکلات پرفورمنس این تابع وارنینگ هایی را صادر می کند . در حالی که اگر کد های توابع Column و Row یا Box را بررسی کنید متوجه می شوید که به صورت inline مارک شده اند .
public inline fun Box(modifier: androidx.compose.ui.Modifier)
مگر inline باعث مشکلات پرفورمنس در کامپایلر کامپوز نمیشود ؟ پس چرا طراحان فریمورک تصمیم گرفته اند تا توابع Box , Column به صورت inline پیاده سازی شود ؟
پاسخ این است که در بحث پرفورمنس دو عامل موثر است : کامپایلر و ران تایم
در مورد inline درست است که در کامپایلر دچار مشکل هستیم اما پس از محاسبات توسط تیم کامپوز احتمالا در ران تایم باعث بهبود چشم گیری می شده است که حتی به issue های کامپایلر نیز غلبه می کرده است .
این توسط طراحان قابل محاسبه بوده است اما برای ما خیر. بنابراین گاهی از اوقات تلاش برای رفع مشکلات کامپایلر به صورت افراطی ممکن است ما را درگیر چالش هایی در زمان ران تایم کند.
نکته : به علت inline بودن Box , Column و Row . آن ها read scope مختص خودشان را ندارند به این معنا که توانایی تولید و خواندن State ها را ندارند و تنها از read scope موجود در parent شان استفاده میکنند . هنگامی که یک state تغییر میکند آن ها recomposition را به نزدیک ترین scope ممکن منتقل می کنند. این نکته باعث افزایش پرفرومنس ران تایم می شود اما همچنان می تواند باعث ایجاد مشکلاتی برای ما بشود (در انتهای مقاله در رابطه با این موارد بیش تر صحبت می کنیم )
در این جا ما شرایط کلی را مطرح میکنیم که skippable بودن مزیت خاصی ندارد . حتی باعث می شود تا نگهداری کد نیز سخت تر باشد :
- کامپوزبلی که اصلا یا غالبا ریکامپوز نمی شود .
- کامپوزبلی که تنها کامپوزبل های قابل skip را صدا میکند (خودش نیازی به skippable بودن ندارد)
- کامپوزبلی که آرگومان های بسیار زیاد با پیاده سازی های سنگینی دارد . در چنین شرایطی تلاش برای stable کردن همه ی آرگومان ها ممکن است هزینه سنگین تری نسبت به recompostion داشته باشد.
نکته مهم 2 : نتایج حاصل از گزارش ها ممکن است در ورژن های مختلف کامپوز متفاوت باشد .
من در ابتدای مقاله ورژن کامپوز خود را ذکر کردم . دلیل آن , اینست که در هر ورژن از کامپوز, کامپایلر آن نیز ممکن است قدرتمند تر شود. بنابراین ممکن است مواردی که در ورژن های قبل unstable در نظر گرفته می شدند در ورژن های جدید stable تلقی شوند .
من در این مقاله سعی کردم تا مواردی که نسبت به وضعیت stability آن ها تردید وجود داشت را ذکر نکنم .
بنابراین لازم است برای هر پروژه ی کامپوزی خود metrics هارا فعال کنید و وضعیت فانکشن های خود را بررسی کنید .
Dynamic expressions in default parameter
هنگامی که از default parameter ها استفاده می کنیم (x:Int = 0) کامپوز احتیاج دارد کد هایی را برای بررسی مقدار و تغییرات آن ها جنریت کند .
دومین چیزی که در گزارش ها باید به دنبال آن باشیم static یا dynamic بودن هنگام استفاده از default parameter هاست .
به کلید واژه ی static که در مثال لامبدا ها آوردیم توجه کنید :
restartable skippable scheme([androidx.compose.ui.UiComposable]) fun stableLambdaScreen(
stable : Function1<Date, Unit>? = @static { it: Date ->
}
ما در موارد بسیاری از default parameter ها استفاده می کنیم .
دیفالت پارامتر ها ممکن است از composable یا non-composable کد ها بیاید (به طور مثال مقدار false از non-composable است اما modifier از composable است )
هنگامی که مقدار پارامتر دیفالت ممکن است در ران تایم تغییر کند کامپایلر آن ها را با dynamic@ مارک می کند (مانند مقادیر حاصل شده از کد های کامپوزی -> به علت restartable بودن این کد ها مقدار دیفالت ممکن است در زمان ران تایم تغییر کند )
در حالی که اگر مقدار پارامتر دیفالت در ران تایم قابل تغییر نباشد کامپایلر آن را static مارک می کند .
از آن جا که قبل تر پیرامون کاهش وظایف ران تایم و تاثیر آن بر پرفورمنس صحبت کردیم هر چه دیفالت پارامتر های ما static باشند پرفورمنس بالا تری حاصل می شود .
به مثال زیر توجه کنید :
@Composable
fun DynamicDefaultParams(
color: Color = MaterialTheme.colorScheme.background
) {}
به دلیل اینکه مقدار دیفالت color از یک کلاس کامپوزی می آید این پارامتر dynamic است .
restartable skippable fun UnstableWithDynamicDefaultParams(
stable color: Color = @dynamic MaterialTheme.colorScheme.background
)
*داینامیک یا استاتیک بودن روی skippable بودن فانکشن ها تاثیری ندارد .
در دو حالت دیفالت پارامتر ها dynamic می شوند و برای static کردن آن ها راه حلی وجود ندارد:
۱) در کد های کامپوزی (مانند MaterialTheme)
۲) در observable ها.(مانند Mutable State) -> زیرا در ران تایم قابل تغییر اند
@Composable
fun DynamicDefaultParams(
mutableState: MutableState<Int> = mutableStateOf(10),
) {}
در این دو حالت راهی برای جلوگیری از dynamic شدن مقادیر نداریم . اما شرایطی وجود دارد که می توانیم مقدار dynamic شده را static کنیم به مثال زیر توجه کنید :
@Composable
fun AnimatedVolumeLevelBar(
barWidth: Dp = 2.dp,
gapWidth: Dp = barWidth
) {}
restartable skippable fun AnimatedVolumeLevelBar(
stable barWidth: Dp = @static 2.dp
stable gapWidth: Dp = @dynamic barWidth
)
در مثال بالا مقدار barWidth به علت قابل تغییر نبودن static است اما مقدار gapWidth به علت وابستگی به مقدار barWidth توسط کامپایلر dynamic تلقی می شود .
به منظور اصلاح این مشکل می توان از کد زیر استفاده کرد :
companion object{
const val DEFAULT_WIDTH = 2
}
@Composable
fun AnimatedVolumeLevelBar(
barWidth: Dp = DEFAULT_WIDTH.dp,
gapWidth: Dp = DEFAULT_WIDTH.dp
) {}
restartable skippable fun AnimatedVolumeLevelBar(
stable barWidth: Dp = @static Companion.DEFAULT_WIDTH.dp
stable gapWidth: Dp = @static Companion.DEFAULT_WIDTH.dp
)
* در واقع تعریف مقادیر درcompanion (static) می تواند یک روش موثر در static کردن برخی از مقادیر dynamic باشد.
*اکنون شما فکر می کنید که فقط ما از نکته ی بالا آگاه هستیم ؟ خیر تیم کامپوز نیز از این نکته آگاه هست.
آن ها با قرار دادن برخی از composable های پر کاربرد در بلاک companion آن ها را استاتیک کرده اند .
به مثال زیر توجه کنید :
@Composable
fun AnimatedVolumeLevelBar(
modifier: Modifier = Modifier,
color: Color = Color.White
) {}
restartable skippable fun AnimatedVolumeLevelBar(
stable modifier: Modifier? = @static Companion
stable color: Color = @static Companion.White
)
علارغم کامپوزی بودن آرگومان های modifier و Color.White آن ها به صورت static مارک شده اند . علت این امر آن است که تیم کامپوز آن ها را در بلاک companion قرار داده است (به کلید واژه ی Companion در گزارش توجه کنید )
تا این جای کار با تاثیر companion بر static شدن دیفالت پارامتر ها آشنا شدیم اما این تنها روش موجود نبود مثال زیر را در نظر بگیرید :
@Composable
fun DefaultParameters(
str: String = stringResource(id = R.string.app_name)
) {}
همانطور که قبلا گفته شد دیفالت های کامپوزی dynamic هستند بنابراین stringResource باعث dynamic شدن دیفالت پارامتر می شود .
restartable skippable fun DefaultParameters(
stable str: String? = @dynamic stringResource(string.app_name, $composer, 0)
)
حالا فرض کنید تابع را به صورت زیر بازنویسی کنیم :
@Composable
fun DefaultParameters(
str: Int = R.string.app_name
) {
val appName = stringResource(id = str)
}
به دلیل آن که مقدار دیفالت پارامتر از کلاس R که در زمان کامپایل جنریت می شود گرفته شده است همچنان این پارامتر dynamic است :
restartable skippable fun DefaultParameters(
stable str: Int = @dynamic string.app_name
)
اما اگر این کد را به صورت زیر تغییر دهیم چه اتفاقی می افتد؟
fun getString(): Int {
return R.string.app_name
}
@Composable
fun DefaultParameters(
str: Int = getString()
) {}
هیچ اتفاقی نمی افتد هنوز مقدار خروجی تابع getStringId به صورت dynamic است
restartable skippable fun DefaultParameters(
stable str: Int = @dynamic getString()
)
تا این جا هیچ تغییر مثبتی ندیدیم ! اما اجازه بدید تا نگاهی به کد انوتیشن Stable@ که قبلا پیرامون آن صحبت کردیم بیندازیم :
همانطور که مشاهده می کنید این انوتیشن روی (کلاس ها , فانکشن ها , پراپرتی ها) قابل اعمال است اگر روی فانکشن getString این انوتیشن را اعمال کنیم چه اتفاقی می افتد؟
@Stable
fun getString(): Int {
return R.string.app_name
}
@Composable
fun DefaultParameters(
str: Int = getString()
) {}
restartable skippable fun DefaultParameters(
stable str: Int = @static getString()
)
بله همانطور که مشاهده میکنید در این جا دیفالت پراپرتی static شد . این از خواص دیگر انوتیشن Stable@ است.
* توجه کنید که من در این جا توصیه نکرده ام که از این به بعد هرگاه به دیفالت آرگومان با مقدار string احتیاج داشتید آن را به صورت int از یک فانکشن که stable مارک شده است return کنید بلکه صرفا روشی را به جهت static کردن مقادیر dynamic معرفی کرده ام .
شاید شما ترجیح بدهید تا برای تمام string ها از stringResource استفاده کنید و به هیچ مشکل پرفورمنسی بر نخورید . شرایط پروژه شما و میزان استفاده از element ها مشخص میکند که شما چه قدر و روی چه مواردی باید حساس باشید .
این مورد در رابطه با MaterialTheme و تمام dynamic های دیگر صادق است .
ما تا این جای مقاله بحث کاملی پیرامون پرفورمنس در کامپوز داشته ایم و در رابطه با stable-unstable در آرگومان ها و static-dynamic در دیفالت پارامتر ها صحبت مفصلی داشتیم . اکنون به انتهای مقاله رسیدیم
اما همانطور که در میانه ی مقاله قول دادیم در رابطه با مشکل inline در box , column صحبت خواهیم کرد :
read scope issue in box , column , row
در قسمت inline در مقاله گفتیم :
نکته : به علت inline بودن Box , Column و Row . آن ها read scope مختص خودشان را ندارند به این معنا که توانایی تولید و خواندن State ها را ندارند و تنها از read scope موجود در parent شان استفاده میکنند . هنگامی که یک state تغییر میکند آن ها recomposition را به نزدیک ترین scope ممکن منتقل می کنند. این نکته باعث افزایش پرفرومنس ران تایم می شود اما همچنان می تواند باعث ایجاد مشکلاتی برای ما بشود (در انتهای مقاله در رابطه با این موارد بیش تر صحبت می کنیم )
اجازه دهید تا با ذکر یک مثال به بیان یک مشکل بپردازیم :
@Composable
fun StateScreen() {
var count by remember { mutableStateOf(0) }
Column {'count: $count')
Button( = { count++ }) {
Text(text = 'count++')
}
}
}
کد بالا را در نظر بگیرید هنگامی که روی باتن کلیک میشود مقدار count زیاد میشود . مقدار count در یک Text و در Button نیز نمایش داده میشود .
به دقت به کد بالا دقت کنید آیا در آن مشکلی میبینید؟
هنگامی که روی باتن کلیک می شود و مقدار count تغییر می کند Text و Button باید ریکامپوز شوند زیرا state آن ها (count) تغییر داشته است . اما مشکل این جا است که اگر ریکامپوزیشن های این تابع را بررسی کنیم متوجه میشویم که خود StateScreen نیز با هر بار کلیک روی باتن ریکامپوز می شود (در حالی که state آن تغییری نداشته است )
به طور مثال اگر من 15 بار روی باتن کلیک کنم فانکشن StateScreen نیز 15 بار ریکامپوز میشود.
مشکل از کجاست ؟ مگر smart recomposition نباید قسمت هایی که احتیاج به تغییر ندارند را skip کند؟
منبع این مشکل را می توان در column جستجو کرد . همانطور که قبلا گفته شد کامپوزبل column به صورت inline مارک شده است . این سبب میشود تا کامپوزبل readScope خود را از دست بدهد . به این معنا که توانایی تولید و پردازش state ها را ندارد و تغییرات state را به نزدیک ترین readScope به خود منتقل می کند .
این امر باعث می شود تا در مثال ما تغییرات state از column به نزدیک ترین scope ممکن که StateScreen است نشت کند و در نتیجه منجر به ریکامپوز های بی مورد در آن شود .
اما این مشکل قابل کنترل است : چه می شود اگر column را در یک composable function دیگر wrapp کنیم ؟ به مثال زیر توجه کنید .
@Composable
fun StateScreen() {
var count by remember { mutableStateOf(0) }
WrappedColumn {
Text(text = 'count: $count')
Button( = { count++ }) {
Text(text = 'increase count')
}
}
}
@Composable
fun WrappedColumn(content: @Composable ColumnScope.() -> Unit) {
Column(content = content)
}
در مثال بالا column در یک تابع composable دیگر wrapp شده است این باعث میشود تا این composable function نقش readScope را برای column بازی کند .
در نتیجه state از WrappedColumn به StateScreen نشت نمی کند .
اکنون اگر من 15 بار روی button کلیک کنم StateScreen به هیچ عنوان ریکامپوز نمی شود ..
ما به انتهای این مقاله رسیدیم . حالا وقتشه که compose metrics رو در پروژه ی خودتون فعال کنید و کامپوزبل های خودتون رو بررسی کنید . ممنون که تا پایان با من همراه بودید .
مطلبی دیگر در همین موضوع
دوره ویدئویی آنلاین و رایگان اموزش Css3 (پیشرفته)
مطلبی دیگر در همین موضوع
نحوه راه اندازی وب اپلیکیشن های آفلاین
بر اساس علایق شما
کفش بزرگان[ناصراعظمی ]