Jetpack Compose یک ابزار مدرن و اعلانی برای ساخت رابط کاربری در اندروید است. Compose نوشتن و نگهداری رابط کاربری اپلیکیشن را سادهتر میکند، چون یک API اعلانی در اختیار شما قرار میدهد که به کمک آن میتوانید رابط کاربری برنامه را بدون تغییر مستقیم و دستوری Viewهای فرانتاند رندر کنید. این اصطلاحات نیاز به توضیح دارند، اما تأثیر آنها روی طراحی اپلیکیشن بسیار مهم است.
در گذشته، سلسلهمراتب View در اندروید بهصورت یک درخت از ویجتهای رابط کاربری نمایش داده میشد. وقتی وضعیت برنامه به دلیل مواردی مانند تعامل کاربر تغییر میکرد، سلسلهمراتب رابط کاربری نیز باید بهروزرسانی میشد تا داده فعلی را نمایش دهد. رایجترین روش برای بهروزرسانی UI این بود که درخت View را با توابعی مثل findViewById() پیمایش کنیم و سپس نودها را با فراخوانی متدهایی مانند button.setText(String)، container.addChild(View) یا img.setImageBitmap(Bitmap) تغییر دهیم. این متدها وضعیت داخلی ویجت را تغییر میدهند.
تغییر دستی Viewها احتمال خطا را افزایش میدهد. اگر یک داده در چند بخش مختلف نمایش داده ود، ممکن است فراموش کنید یکی از Viewهایی را که آن داده را نشان میدهد بهروزرسانی کنید. این موضوع همچنین میتواند باعث ایجاد وضعیتهای نامعتبر شود؛ مثلاً وقتی دو بهروزرسانی به شکل غیرمنتظره با هم تداخل پیدا کنند. برای نمونه، یک بهروزرسانی ممکن است تلاش کند مقدار یک نود را تغییر دهد، در حالی که آن نود همین حالا از UI حذف شده است. بهطور کلی، پیچیدگی نگهداری نرمافزار با تعداد Viewهایی که نیاز به بهروزرسانی دارند افزایش پیدا میکند.
در چند سال گذشته، کل صنعت نرمافزار به سمت مدل رابط کاربری اعلانی حرکت کرده است. این مدل، فرایند مهندسی مربوط به ساخت و بهروزرسانی رابطهای کاربری را سادهتر میکند. روش کار به این صورت است که از نظر مفهومی کل صفحه از ابتدا دوباره تولید میشود و سپس فقط تغییرات لازم اعمال میشوند. این رویکرد از پیچیدگی بهروزرسانی دستی یک سلسلهمراتب View دارای State جلوگیری میکند. Compose یک فریمورک اعلانی برای رابط کاربری است.
یکی از چالشهای تولید دوباره کل صفحه این است که از نظر زمان، قدرت پردازشی و مصرف باتری میتواند هزینهبر باشد. برای کاهش این هزینه، Compose بهصورت هوشمند انتخاب میکند که در هر لحظه کدام بخشهای UI باید دوباره رسم شوند. این موضوع روی نحوه طراحی کامپوننتهای UI تأثیر دارد که در بخش Recomposition درباره آن صحبت میشود.
با استفاده از Compose، میتوانید رابط کاربری خود را با تعریف مجموعهای از توابع Composable بسازید. این توابع داده دریافت میکنند و عناصر UI تولید میکنند. برای مثال، یک ویجت Greeting یک مقدار از نوع String دریافت میکند و یک ویجت Text تولید میکند که پیام خوشامدگویی را نمایش میدهد.
شکل ۱. یک تابع Composable که داده دریافت میکند و از آن برای رندر کردن یک ویجت متنی روی صفحه استفاده میکند.
چند نکته مهم درباره این تابع:
Annotation: این تابع با annotation به نام @Composable مشخص شده است. همه توابع Composable باید این annotation را داشته باشند. این annotation به کامپایلر Compose اطلاع میدهد که این تابع قرار است داده را به رابط کاربری تبدیل کند.
ورودی داده: تابع داده دریافت میکند. توابع Composable میتوانند پارامتر بپذیرند و این پارامترها به منطق برنامه اجازه میدهند UI را توصیف کند. در این مثال، ویجت ما یک String دریافت میکند تا بتواند با نام کاربر به او خوشامد بگوید.
نمایش UI: تابع متن را در رابط کاربری نمایش میدهد. این کار را با فراخوانی تابع Composable به نام Text() انجام میدهد که در واقع عنصر متنی UI را ایجاد میکند. توابع Composable با فراخوانی سایر توابع Composable، سلسلهمراتب UI را تولید میکنند.
نداشتن مقدار بازگشتی: این تابع چیزی برنمیگرداند. توابع Compose که UI تولید میکنند نیازی به بازگرداندن مقدار ندارند، چون آنها وضعیت هدف صفحه را توصیف میکنند، نه اینکه ویجتهای UI را بهصورت مستقیم بسازند.
ویژگیها: این تابع سریع، idempotent و بدون side-effect است.
تابع وقتی چند بار با یک آرگومان یکسان فراخوانی شود، رفتار یکسانی دارد و از مقادیر دیگری مانند متغیرهای global یا فراخوانی random() استفاده نمیکند.
تابع UI را بدون هیچ side-effect توصیف میکند؛ یعنی بدون تغییر دادن propertyها یا متغیرهای global.
بهطور کلی، همه توابع Composable باید با این ویژگیها نوشته شوند؛ دلایل آن در بخش Recomposition توضیح داده میشود.
در بسیاری از ابزارهای رابط کاربری دستوری و شیءگرا، شما UI را با ساختن یک درخت از ویجتها مقداردهی اولیه میکنید. معمولاً این کار را با inflate کردن یک فایل XML layout انجام میدهید. هر ویجت وضعیت داخلی خودش را نگه میدارد و متدهای getter و setter ارائه میکند تا منطق برنامه بتواند با آن ویجت تعامل داشته باشد.
در رویکرد اعلانی Compose، ویجتها نسبتاً بدون State هستند و توابع setter یا getter ارائه نمیکنند. در واقع، ویجتها بهعنوان object در اختیار شما قرار نمیگیرند. شما UI را با فراخوانی همان تابع Composable با آرگومانهای متفاوت بهروزرسانی میکنید. این کار ارائه State به الگوهای معماری مانند ViewModel را سادهتر میکند. سپس Composableهای شما مسئول هستند هر بار که داده قابل مشاهده تغییر میکند، وضعیت فعلی برنامه را به UI تبدیل کنند.
شکل ۲. منطق برنامه داده را به تابع Composable سطح بالا میدهد. آن تابع با استفاده از داده، UI را با فراخوانی سایر Composableها توصیف میکند و داده مناسب را به آنها و سپس به بخشهای پایینتر سلسلهمراتب منتقل میکند.
وقتی کاربر با UI تعامل میکند، UI رویدادهایی مانند onClick ایجاد میکند. این رویدادها باید منطق برنامه را مطلع کنند تا منطق برنامه بتواند State اپلیکیشن را تغییر دهد. وقتی State تغییر کند، توابع Composable دوباره با داده جدید فراخوانی میشوند. این باعث میشود عناصر UI دوباره رسم شوند. این فرایند Recomposition نام دارد.
شکل ۳. کاربر با یک عنصر UI تعامل کرده و باعث ایجاد یک رویداد شده است. منطق برنامه به آن رویداد پاسخ میدهد، سپس در صورت نیاز، توابع Composable بهصورت خودکار با پارامترهای جدید دوباره فراخوانی میشوند.
از آنجایی که توابع Composable بهجای XML با Kotlin نوشته میشوند، میتوانند مانند هر کد Kotlin دیگری پویا باشند. برای مثال، فرض کنید میخواهید یک UI بسازید که به فهرستی از کاربران خوشامد بگوید:
@Composable
fun Greeting(names: List<String>) {
for (name in names) {
Text("Hello $name")
}
}
این تابع فهرستی از نامها را دریافت میکند و برای هر کاربر یک پیام خوشامدگویی تولید میکند. توابع Composable میتوانند بسیار پیشرفته باشند. میتوانید از دستورهای if استفاده کنید تا تصمیم بگیرید آیا یک عنصر UI نمایش داده شود یا نه. میتوانید از حلقهها استفاده کنید. میتوانید توابع کمکی را فراخوانی کنید. شما انعطاف کامل زبان Kotlin را در اختیار دارید. این قدرت و انعطافپذیری یکی از مزیتهای اصلی Jetpack Compose است.
در مدل دستوری UI، برای تغییر یک ویجت، یک setter روی آن ویجت فراخوانی میکنید تا وضعیت داخلی آن تغییر کند. در Compose، تابع Composable را دوباره با داده جدید فراخوانی میکنید. این کار باعث میشود تابع recomposed شود؛ یعنی ویجتهایی که توسط تابع تولید شدهاند، در صورت نیاز، با داده جدید دوباره رسم شوند. فریمورک Compose میتواند بهصورت هوشمند فقط کامپوننتهایی را recomposition کند که تغییر کردهاند.
برای مثال، این تابع Composable را در نظر بگیرید که یک دکمه نمایش میدهد:
@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
Button(onClick = onClick) {
Text("I've been clicked $clicks times")
}
}
هر بار که دکمه کلیک میشود، caller مقدار clicks را بهروزرسانی میکند. Compose دوباره lambda مربوط به تابع Text را فراخوانی میکند تا مقدار جدید نمایش داده شود. این فرایند Recomposition نام دارد. توابع دیگری که به آن مقدار وابسته نیستند، recomposition نمیشوند.
همانطور که گفته شد، recomposition کل درخت UI میتواند از نظر پردازشی هزینهبر باشد و باعث مصرف قدرت پردازشی و باتری شود. Compose این مشکل را با recomposition هوشمند حل میکند.
Recomposition فرایند فراخوانی دوباره توابع Composable شما هنگام تغییر ورودیها است. وقتی Compose بر اساس ورودیهای جدید recomposition انجام میدهد، فقط توابع یا lambdaهایی را فراخوانی میکند که ممکن است تغییر کرده باشند و بقیه را رد میکند. با رد کردن توابع یا lambdaهایی که پارامترهایشان تغییر نکرده است، Compose بهصورت بهینه recomposition انجام میدهد.
هرگز به side-effectهای ناشی از اجرای توابع Composable وابسته نباشید، چون ممکن است recomposition یک تابع رد شود. اگر این کار را انجام دهید، کاربران ممکن است رفتارهای عجیب و غیرقابل پیشبینی در اپلیکیشن شما تجربه کنند. Side-effect به هر تغییری گفته میشود که برای سایر بخشهای برنامه قابل مشاهده باشد. برای مثال، این کارها side-effectهای خطرناک هستند:
نوشتن در یک property از یک object مشترک
بهروزرسانی یک observable در ViewModel
بهروزرسانی SharedPreferences
توابع Composable ممکن است به اندازه هر فریم، مثلاً هنگام رندر شدن یک animation، دوباره اجرا شوند. توابع Composable باید سریع باشند تا هنگام animation باعث jank نشوند. اگر نیاز دارید عملیات سنگینی مانند خواندن از SharedPreferences انجام دهید، آن را در یک coroutine پسزمینه انجام دهید و مقدار نتیجه را بهعنوان پارامتر به تابع Composable ارسال کنید.
برای مثال، کد زیر یک Composable برای بهروزرسانی مقداری در SharedPreferences ایجاد میکند. Composable نباید خودش از SharedPreferences بخواند یا در آن بنویسد. در عوض، این کد عملیات خواندن و نوشتن را به یک ViewModel در یک coroutine پسزمینه منتقل میکند. منطق برنامه مقدار فعلی را همراه با یک callback برای اجرای بهروزرسانی ارسال میکند.
@Composable
fun SharedPrefsToggle(
text: String,
value: Boolean,
onValueChanged: (Boolean) -> Unit
) {
Row {
Text(text)
Checkbox(checked = value, onCheckedChange = onValueChanged)
}
}
این سند چند نکته را توضیح میدهد که هنگام استفاده از Compose باید به آنها توجه کنید:
Recomposition تا حد امکان توابع Composable و lambdaها را رد میکند.
Recomposition خوشبینانه است و ممکن است لغو شود.
یک تابع Composable ممکن است بسیار زیاد اجرا شود؛ حتی به اندازه هر فریم از یک animation.
توابع Composable میتوانند بهصورت موازی اجرا شوند.
توابع Composable میتوانند با هر ترتیبی اجرا شوند.
بخشهای بعدی توضیح میدهند چگونه توابع Composable را طوری بسازید که از recomposition پشتیبانی کنند. در همه موارد، بهترین روش این است که توابع Composable خود را سریع، idempotent و بدون side-effect نگه دارید.
وقتی بخشهایی از UI شما نامعتبر میشوند، Compose تمام تلاش خود را میکند که فقط همان بخشهایی را recomposition کند که نیاز به بهروزرسانی دارند. این یعنی ممکن است Compose فقط Composable مربوط به یک Button را دوباره اجرا کند، بدون اینکه هیچیک از Composableهای بالاتر یا پایینتر در درخت UI اجرا شوند.
هر تابع Composable و هر lambda ممکن است بهتنهایی recomposition شود. مثال زیر نشان میدهد که هنگام رندر کردن یک لیست، recomposition چگونه میتواند بعضی عناصر را رد کند:
/**
* نمایش فهرستی از نامها که کاربر میتواند روی آنها کلیک کند، همراه با یک header
*/
@Composable
fun NamePicker(
header: String,
names: List<String>,
onNameClicked: (String) -> Unit
) {
Column {
// این بخش وقتی [header] تغییر کند recomposition میشود،
// اما وقتی [names] تغییر کند recomposition نمیشود
Text(header, style = MaterialTheme.typography.bodyLarge)
HorizontalDivider()
// LazyColumn نسخه Compose از RecyclerView است.
LazyColumn {
items(names) { name ->
// وقتی [name] یک آیتم بهروزرسانی شود،
// adapter مربوط به آن آیتم recomposition میشود.
NamePickerItem(name, onNameClicked)
}
}
}
}
/**
* نمایش یک نام که کاربر میتواند روی آن کلیک کند.
*/
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}
هرکدام از این scopeها ممکن است تنها بخشی باشند که هنگام recomposition اجرا میشود. وقتی header تغییر میکند، Compose ممکن است بدون اجرای parentها، مستقیماً به lambda مربوط به Column برود. همچنین هنگام اجرای Column، اگر names تغییر نکرده باشد، Compose ممکن است تصمیم بگیرد آیتمهای LazyColumn را رد کند.
دوباره تأکید میشود که همه توابع Composable یا lambdaها باید بدون side-effect باشند. وقتی لازم است یک side-effect انجام دهید، آن را از یک callback اجرا کنید.
Recomposition هر زمانی شروع میشود که Compose فکر کند پارامترهای یک Composable ممکن است تغییر کرده باشند. Recomposition خوشبینانه است؛ یعنی Compose انتظار دارد قبل از اینکه پارامترها دوباره تغییر کنند، recomposition را تمام کند. اگر یک پارامتر قبل از پایان recomposition تغییر کند، Compose ممکن است recomposition را لغو کند و آن را با پارامتر جدید دوباره شروع کند.
وقتی recomposition لغو میشود، Compose درخت UI مربوط به آن recomposition را کنار میگذارد. اگر side-effectهایی داشته باشید که به نمایش داده شدن UI وابسته باشند، آن side-effect حتی اگر composition لغو شده باشد نیز اعمال میشود. این موضوع میتواند باعث ناسازگاری در State برنامه شود.
برای مدیریت درست recomposition خوشبینانه، مطمئن شوید همه توابع Composable و lambdaها idempotent و بدون side-effect هستند.
در برخی موارد، یک تابع Composable ممکن است برای هر فریم از یک animation اجرا شود. اگر تابع عملیات سنگینی انجام دهد، مثلاً از حافظه دستگاه بخواند، میتواند باعث jank در UI شود.
برای مثال، اگر ویجت شما تلاش کند تنظیمات دستگاه را بخواند، ممکن است این تنظیمات را صدها بار در ثانیه بخواند که تأثیر بسیار بدی روی عملکرد اپلیکیشن خواهد داشت.
اگر یک تابع Composable به داده نیاز دارد، برای آن داده پارامتر تعریف کنید. سپس میتوانید کارهای سنگین را به یک thread دیگر خارج از composition منتقل کنید و مقدار حاصل را با استفاده از mutableStateOf یا LiveData بهعنوان پارامتر به تابع Composable بدهید.
نکته: توابع Composable در حال حاضر نمیتوانند بهصورت موازی اجرا شوند، اما باید کد Compose را به شکلی بنویسید که برای محیط چندریسمانی مناسب باشد. در آینده، Compose ممکن است چندریسمانی شود.
Compose میتواند recomposition را با اجرای موازی توابع Composable بهینه کند. این کار به Compose اجازه میدهد از چندین هسته پردازنده استفاده کند و توابع Composable مربوط به بخشهایی را که روی صفحه نیستند با اولویت پایینتر اجرا کند.
این بهینهسازی به این معناست که یک تابع Composable ممکن است در مجموعهای از threadهای پسزمینه اجرا شود. اگر یک تابع Composable تابعی را روی ViewModel فراخوانی کند، Compose ممکن است آن تابع را همزمان از چند thread مختلف فراخوانی کند.
برای اطمینان از اینکه برنامه شما درست کار میکند، همه توابع Composable باید بدون side-effect باشند. در عوض، side-effectها را از callbackهایی مانند onClick اجرا کنید که همیشه روی UI thread اجرا میشوند.
وقتی یک تابع Composable فراخوانی میشود، این فراخوانی ممکن است روی thread متفاوتی نسبت به caller انجام شود. یعنی باید از کدی که متغیرها را داخل یک lambda مربوط به Composable تغییر میدهد دوری کنید؛ هم چون چنین کدی thread-safe نیست، و هم چون یک side-effect غیرمجاز برای lambda مربوط به Composable محسوب میشود.
در اینجا مثالی آمده است که یک Composable را نشان میدهد که فهرست و تعداد آیتمهای آن را نمایش میدهد:
@Composable
fun ListComposable(myList: List<String>) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
}
}
Text("Count: ${myList.size}")
}
}
@Composable
fun ListWithBug(myList: List<String>) {
var items = 0
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Card {
Text("Item: $item")
items++ // اجتناب کنید! این یک side-effect ناشی از recomposition شدن Column است.
}
}
}
Text("Count: $items")
}
}
در این مثال، مقدار items با هر recomposition تغییر میکند. این ممکن است برای هر فریم از یک animation رخ دهد یا زمانی که لیست بهروزرسانی میشود. در هر حالت، UI تعداد اشتباه را نمایش خواهد داد. به همین دلیل، چنین نوشتنهایی در Compose پشتیبانی نمیشوند. با ممنوع کردن این نوع نوشتن، به فریمورک اجازه میدهیم threadها را برای اجرای lambdaهای Composable تغییر دهد.
نکته: Compose روی thread اصلی کار میکند، اما از ابتدا با در نظر گرفتن چندریسمانی طراحی شده است و تضمین نمیکنیم که مدل تکریسمانی در آینده باقی بماند. به همین دلیل، همیشه باید Composableهای خود را طوری بنویسید که انگار ممکن است روی چند thread اجرا شوند.
اگر به کد یک تابع Composable نگاه کنید، ممکن است فرض کنید کد به همان ترتیبی که نوشته شده اجرا میشود. اما تضمینی وجود ندارد که این درست باشد. اگر یک تابع Composable شامل فراخوانی توابع Composable دیگر باشد، آن توابع ممکن است با هر ترتیبی اجرا شوند. Compose این امکان را دارد که تشخیص دهد بعضی عناصر UI نسبت به بقیه اولویت بالاتری دارند و آنها را زودتر رسم کند.
برای مثال، فرض کنید کدی مانند زیر دارید که سه صفحه را در یک tab layout رسم میکند:
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen()
EndScreen()
}
}
StartScreen، MiddleScreen و EndScreen ممکن است با هر ترتیبی انجام شوند. این یعنی نمیتوانید مثلاً کاری کنید که StartScreen() یک متغیر global را تنظیم کند و سپس MiddleScreen() از آن تغییر استفاده کند. در عوض، هرکدام از این توابع باید مستقل و self-contained باشند.این محتوا کاملا رایگان توسط تیم کدلپر ترجمه شده و در اختیار شما کاربران عزیز قرار گرفته است، هر گونه کپی برداری برای مقاصد غیر رایگان و بدون ذکر منبع، مورد پیگیری قانونی قرار میگیرد.
ترجمه شده از منبع: https://developer.android.com/develop/ui/compose/documentation