در یک اپلیکیشن Compose، اینکه UI State را کجا Hoist کنید به این بستگی دارد که UI logic به آن نیاز دارد یا Business logic. این سند این دو سناریوی اصلی را توضیح میدهد.
بهترین روش
باید UI State را تا پایینترین والد مشترک بین همه Composableهایی که آن را میخوانند و مینویسند Hoist کنید. State را تا حد امکان نزدیک به جایی نگه دارید که مصرف میشود. از مالک State، یک State غیرقابل تغییر و Eventهایی برای تغییر State در اختیار مصرفکنندهها قرار دهید.
پایینترین والد مشترک میتواند خارج از Composition هم باشد. برای مثال، زمانی که State را در ViewModel Hoist میکنید، چون Business logic درگیر است.
این صفحه این بهترین روش را با جزئیات توضیح میدهد و یک نکته احتیاطی را نیز بیان میکند.
انواع UI State و UI Logic
در ادامه، تعریفهایی برای انواع UI State و Logic آمده است که در این سند استفاده میشوند.
UI State
UI State ویژگیای است که UI را توصیف میکند. دو نوع UI State وجود دارد:
Screen UI state چیزی است که برای نمایش روی صفحه به آن نیاز دارید. برای مثال، یک کلاس NewsUiState میتواند شامل مقالههای خبری و اطلاعات دیگری باشد که برای رندر کردن UI لازم هستند. این State معمولاً با لایههای دیگر معماری ارتباط دارد، چون شامل دادههای اپلیکیشن است.
UI element state به ویژگیهای ذاتی عناصر UI گفته میشود که روی نحوه رندر شدن آنها اثر میگذارند. یک عنصر UI ممکن است نمایش داده شود یا مخفی باشد، یا فونت، اندازه فونت یا رنگ فونت خاصی داشته باشد. در Jetpack Compose، State بیرون از Composable قرار دارد و حتی میتوانید آن را از نزدیکی مستقیم Composable خارج کرده و به تابع Composable فراخواننده یا یک State holder منتقل کنید. نمونهای از این مورد ScaffoldState برای Composable مربوط به Scaffold است.
Logic
Logic در یک اپلیکیشن میتواند Business logic یا UI logic باشد.
Business logic پیادهسازی نیازمندیهای محصول برای دادههای اپلیکیشن است. برای مثال، Bookmark کردن یک مقاله در اپلیکیشن خبرخوان وقتی کاربر روی دکمه کلیک میکند. این منطق برای ذخیره Bookmark در فایل یا پایگاه داده معمولاً در لایههای Domain یا Data قرار میگیرد. State holder معمولاً این منطق را با فراخوانی متدهایی که آن لایهها ارائه میدهند، به آنها واگذار میکند.
UI logic به نحوه نمایش UI State روی صفحه مربوط است. برای مثال، گرفتن hint مناسب برای نوار جستوجو وقتی کاربر یک دستهبندی را انتخاب کرده است، اسکرول کردن به یک آیتم خاص در لیست، یا منطق Navigation به یک صفحه خاص وقتی کاربر روی یک دکمه کلیک میکند.
UI Logic
وقتی UI logic نیاز دارد State را بخواند یا بنویسد، باید State را در محدوده UI قرار دهید و چرخه عمر UI را دنبال کنید. برای انجام این کار، باید State را در سطح مناسب داخل یک تابع Composable Hoist کنید. همچنین میتوانید این کار را در یک کلاس State holder ساده انجام دهید که آن هم به چرخه عمر UI محدود شده است.
در ادامه هر دو راهحل توضیح داده میشود و مشخص میشود چه زمانی باید از هرکدام استفاده کنید.
Composableها بهعنوان مالک State
داشتن UI logic و UI element state داخل Composableها زمانی روش خوبی است که State و Logic ساده باشند. میتوانید State را داخل Composable نگه دارید یا در صورت نیاز آن را Hoist کنید.
نیازی به State Hoisting نیست
Hoist کردن State همیشه لازم نیست. وقتی هیچ Composable دیگری نیاز ندارد State را کنترل کند، میتوان State را داخل همان Composable نگه داشت. در قطعهکد زیر، یک Composable وجود دارد که با لمس کاربر باز و بسته میشود:
@Composable
fun ChatBubble(
message: Message
) {
var showDetails by rememberSaveable { mutableStateOf(false) } // تعریف State باز بودن UI element
Text(
text = AnnotatedString(message.content),
modifier = Modifier.clickable {
showDetails = !showDetails // اعمال UI logic
}
)
if (showDetails) {
Text(message.timestamp)
}
}
متغیر showDetails همان State داخلی این UI element است. این State فقط در همین Composable خوانده و تغییر داده میشود و منطقی که روی آن اعمال میشود بسیار ساده است. بنابراین Hoist کردن State در این مورد مزیت زیادی ندارد و میتوانید آن را داخلی نگه دارید. با این کار، همین Composable مالک و منبع حقیقت واحد برای State باز یا بسته بودن میشود.
نکته کلیدی: نگه داشتن UI element state داخل توابع Composable قابل قبول است. این راهحل زمانی مناسب است که State و منطقی که روی آن اعمال میکنید ساده باشد و بخشهای دیگر سلسلهمراتب UI به آن State نیاز نداشته باشند. برای مثال، معمولاً State مربوط به Animation در همین دسته قرار میگیرد.
Hoisting داخل Composableها
اگر لازم است UI element state خود را با Composableهای دیگر به اشتراک بگذارید و UI logic را در بخشهای مختلف روی آن اعمال کنید، میتوانید State را در سلسلهمراتب UI به سطح بالاتری Hoist کنید. این کار همچنین باعث میشود Composableهای شما قابل استفاده مجددتر و تستپذیرتر شوند.
مثال زیر یک اپلیکیشن چت است که دو قابلیت را پیادهسازی میکند:
- دکمه
JumpToBottomلیست پیامها را به پایین اسکرول میکند. این دکمه UI logic را روی State لیست اعمال میکند. - لیست
MessagesListپس از ارسال پیام جدید توسط کاربر، به پایین اسکرول میشود.UserInputمنطق UI را روی State لیست اعمال میکند.
State مربوط به LazyColumn به صفحه Conversation Hoist میشود تا اپلیکیشن بتواند UI logic را اجرا کند و State را از همه Composableهایی که به آن نیاز دارند بخواند.
کد به شکل زیر است:
@Composable
private fun ConversationScreen(/*...*/) {
val scope = rememberCoroutineScope()
val lazyListState = rememberLazyListState() // State به ConversationScreen منتقل شده است
MessagesList(messages, lazyListState) // استفاده دوباره از همان State در MessageList
UserInput(
onMessageSent = { // اعمال UI logic روی lazyListState
scope.launch {
lazyListState.scrollToItem(0)
}
},
)
}
@Composable
private fun MessagesList(
messages: List<Message>,
lazyListState: LazyListState = rememberLazyListState() // LazyListState مقدار پیشفرض دارد
) {
LazyColumn(
state = lazyListState // ارسال State hoist شده به LazyColumn
) {
items(messages, key = { message -> message.id }) { item ->
Message(/*...*/)
}
}
val scope = rememberCoroutineScope()
JumpToBottom(onClicked = {
scope.launch {
lazyListState.scrollToItem(0) // اعمال UI logic روی lazyListState
}
})
}
LazyListState تا بالاترین سطحی که برای اجرای UI logic لازم است Hoist شده است. از آنجا که این State داخل یک تابع Composable مقداردهی اولیه شده، در Composition ذخیره میشود و چرخه عمر آن را دنبال میکند.
توجه کنید که lazyListState در متد MessagesList با مقدار پیشفرض rememberLazyListState() تعریف شده است. این یک الگوی رایج در Compose است. این کار Composableها را قابل استفاده مجددتر و انعطافپذیرتر میکند. سپس میتوانید همان Composable را در بخشهای مختلف اپلیکیشن استفاده کنید، حتی جاهایی که نیازی به کنترل State ندارند. این مورد معمولاً هنگام تست یا Preview کردن یک Composable کاربرد دارد. LazyColumn نیز State خود را دقیقاً به همین شکل تعریف میکند.
نکته کلیدی: State را به پایینترین والد مشترک Hoist کنید و از ارسال آن به Composableهایی که به آن نیاز ندارند خودداری کنید.
کلاس State holder ساده بهعنوان مالک State
وقتی یک Composable شامل UI logic پیچیدهای باشد که با یک یا چند فیلد State مربوط به یک UI element سر و کار دارد، بهتر است این مسئولیت را به State holderها، مانند یک کلاس State holder ساده، واگذار کند.
این کار باعث میشود منطق Composable بهصورت جداگانه قابل تستتر شود و پیچیدگی آن کاهش پیدا کند. این رویکرد اصل Separation of Concerns را تقویت میکند: Composable مسئول تولید عناصر UI است و State holder شامل UI logic و UI element state میشود.
کلاسهای State holder ساده، توابع راحتی را در اختیار فراخوانندههای تابع Composable قرار میدهند تا آنها مجبور نباشند خودشان این منطق را بنویسند.
این کلاسهای ساده داخل Composition ساخته و remember میشوند. از آنجا که چرخه عمر Composable را دنبال میکنند، میتوانند از نوعهایی استفاده کنند که توسط کتابخانه Compose ارائه میشوند؛ مانند rememberNavController() یا rememberLazyListState().
یک نمونه از این مورد، کلاس State holder ساده LazyListState است که در Compose پیادهسازی شده تا پیچیدگی UI مربوط به LazyColumn یا LazyRow را کنترل کند.
// LazyListState.kt
@Stable
class LazyListState constructor(
firstVisibleItemIndex: Int = 0,
firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
/**
* کلاس holder برای موقعیت فعلی اسکرول
*/
private val scrollPosition = LazyListScrollPosition(
firstVisibleItemIndex, firstVisibleItemScrollOffset
)
suspend fun scrollToItem(/*...*/) { /*...*/ }
override suspend fun scroll() { /*...*/ }
suspend fun animateScrollToItem() { /*...*/ }
}
LazyListState، State مربوط به LazyColumn را کپسوله میکند و موقعیت اسکرول این UI element را در scrollPosition ذخیره میکند. همچنین متدهایی برای تغییر موقعیت اسکرول ارائه میدهد؛ مثلاً اسکرول کردن به یک آیتم مشخص.
نکته: این کلاس با @Stable مشخص شده است. برای اطلاعات بیشتر درباره Stability در Compose، مقاله مربوط به آن را ببینید.
همانطور که میبینید، افزایش مسئولیتهای یک Composable نیاز به State holder را بیشتر میکند. این مسئولیتها میتوانند مربوط به UI logic باشند یا فقط به تعداد Stateهایی مربوط باشند که باید دنبال شوند.
نکته: اگر کلاسهای State holder ساده شامل Stateای هستند که میخواهید پس از بازسازی Activity یا Process حفظ شود، از rememberSaveable استفاده کنید و برای آن یک Saver سفارشی بسازید.
یک الگوی رایج دیگر این است که از یک کلاس State holder ساده برای مدیریت پیچیدگی توابع Composable ریشهای در اپلیکیشن استفاده کنید. میتوانید از چنین کلاسی برای کپسوله کردن Stateهای سطح اپلیکیشن، مانند Navigation state و Screen sizing استفاده کنید. توضیح کامل این موضوع در صفحه UI logic and its state holder آمده است.
Business Logic
اگر Composableها و کلاسهای State holder ساده مسئول UI logic و UI element state باشند، یک State holder در سطح صفحه مسئول کارهای زیر است:
- فراهم کردن دسترسی به Business logic اپلیکیشن که معمولاً در لایههای دیگر معماری مانند Business و Data قرار دارد.
- آماده کردن دادههای اپلیکیشن برای نمایش در یک صفحه خاص، که تبدیل به Screen UI state میشود.
ViewModelها بهعنوان مالک State
مزیتهای AAC ViewModelها در توسعه اندروید باعث میشود برای فراهم کردن دسترسی به Business logic و آمادهسازی دادههای اپلیکیشن برای نمایش روی صفحه مناسب باشند.
نکته کلیدی: ViewModel فقط یک جزئیات پیادهسازی از یک State holder با مسئولیتهای مشخص است. اگر میخواهید ماژول پروژه خود را از وابستگیهای اندروید دور نگه دارید، میتوانید از Interfaceها استفاده کنید تا پیادهسازی در Contextهای مختلف قابل تعویض باشد. برای مثال، میتوانید در ماژول مخصوص اندروید از ViewModel استفاده کنید و در ماژولهای مستقل از پلتفرم از پیادهسازیهای سادهتر، مانند یک کلاس State holder ساده، استفاده کنید.
وقتی UI state را در ViewModel Hoist میکنید، آن را از Composition خارج میکنید.
ViewModelها بهعنوان بخشی از Composition ذخیره نمیشوند. آنها توسط Framework فراهم میشوند و به یک ViewModelStoreOwner محدود میشوند که میتواند یک Activity، Fragment، Navigation graph یا مقصدی از یک Navigation graph باشد. برای اطلاعات بیشتر درباره Scopeهای ViewModel میتوانید مستندات مربوطه را ببینید.
در این حالت، ViewModel منبع حقیقت و پایینترین والد مشترک برای UI state است.
Screen UI State
بر اساس تعریفهای بالا، Screen UI state با اعمال Business ruleها تولید میشود. از آنجا که State holder سطح صفحه مسئول آن است، این یعنی Screen UI state معمولاً در State holder سطح صفحه Hoist میشود؛ در این مورد، داخل یک ViewModel.
ConversationViewModel یک اپلیکیشن چت را در نظر بگیرید و اینکه چگونه Screen UI state و Eventهایی برای تغییر آن را ارائه میدهد:
class ConversationViewModel(
channelId: String,
messagesRepository: MessagesRepository
) : ViewModel() {
val messages = messagesRepository
.getLatestMessages(channelId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// Business logic
fun sendMessage(message: Message) { /* ... */ }
}
Composableها Screen UI stateای را که در ViewModel Hoist شده مصرف میکنند. باید نمونه ViewModel را در Composableهای سطح صفحه inject کنید تا دسترسی به Business logic فراهم شود.
نکته: نباید نمونههای ViewModel را به Composableهای دیگر در لایههای پایینتر پاس دهید. برای اطلاعات بیشتر، مستندات Architecture state holders را ببینید.
مثال زیر استفاده از ViewModel در یک Composable سطح صفحه را نشان میدهد. در اینجا، Composable مربوط به ConversationScreen()، Screen UI stateای را که در ViewModel Hoist شده مصرف میکند:
@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {
val messages by conversationViewModel.messages.collectAsStateWithLifecycle()
ConversationScreen(
messages = messages,
onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
)
}
@Composable
private fun ConversationScreen(
messages: List<Message>,
onSendMessage: (Message) -> Unit
) {
MessagesList(messages, onSendMessage)
/* ... */
}
نکته: برای استفاده از تابع viewModel()، وابستگی androidx.lifecycle:lifecycle-viewmodel-compose را به فایل build.gradle اضافه کنید. برای یادگیری بیشتر درباره این تابع، مستندات کار با کتابخانههای دیگر در Compose را ببینید.
نکته: اگر ViewModelها شامل Stateای هستند که میخواهید پس از بازسازی Process توسط سیستم حفظ شود، از SavedStateHandle برای ماندگار کردن آن استفاده کنید. برای اطلاعات بیشتر، صفحه Saving UI states را ببینید.
Property Drilling
Property drilling به معنی پاس دادن داده از میان چند کامپوننت فرزند تو در تو تا جایی است که آن داده خوانده میشود.
یک مثال رایج از جایی که Property drilling در Compose ظاهر میشود زمانی است که State holder سطح صفحه را در سطح بالا inject میکنید و سپس State و Eventها را به Composableهای فرزند پاس میدهید. این کار ممکن است باعث شود امضای توابع Composable بیش از حد شلوغ شود.
با اینکه ارائه Eventها بهصورت پارامترهای lambda جداگانه میتواند امضای تابع را شلوغ کند، اما باعث میشود مسئولیتهای تابع Composable شفافتر باشند. با یک نگاه میتوانید ببینید آن تابع چه کاری انجام میدهد.
Property drilling نسبت به ساخت wrapper class برای کپسوله کردن State و Eventها در یک مکان، ترجیح داده میشود؛ چون wrapper classها شفافیت مسئولیتهای Composable را کاهش میدهند. وقتی wrapper class نداشته باشید، احتمال بیشتری دارد که فقط همان پارامترهایی را به Composableها پاس دهید که واقعاً به آنها نیاز دارند، و این یک بهترین روش است.
همین بهترین روش برای Eventهای مربوط به Navigation نیز صدق میکند. برای یادگیری بیشتر میتوانید مستندات Navigation را ببینید.
اگر یک مشکل عملکردی شناسایی کردهاید، میتوانید خواندن State را به تعویق بیندازید. برای اطلاعات بیشتر، مستندات Performance را بررسی کنید.
UI Element State
اگر Business logic نیاز داشته باشد UI element state را بخواند یا بنویسد، میتوانید UI element state را به State holder سطح صفحه Hoist کنید.
در ادامه مثال اپلیکیشن چت، برنامه زمانی که کاربر در یک گروه چت کاراکتر @ و یک hint تایپ میکند، پیشنهادهای کاربری نمایش میدهد. این پیشنهادها از لایه Data میآیند و منطق محاسبه لیست پیشنهادهای کاربری، Business logic محسوب میشود.
ViewModel پیادهسازیکننده این قابلیت به شکل زیر خواهد بود:
class ConversationViewModel(/*...*/) : ViewModel() {
// State hoisted شده
var inputMessage by mutableStateOf("")
private set
val suggestions: StateFlow<List<Suggestion>> =
snapshotFlow { inputMessage }
.filter { hasSocialHandleHint(it) }
.mapLatest { getHandle(it) }
.mapLatest { repository.getSuggestions(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun updateInput(newInput: String) {
inputMessage = newInput
}
}
inputMessage متغیری است که State مربوط به TextField را ذخیره میکند. هر بار که کاربر ورودی جدیدی تایپ میکند، اپلیکیشن Business logic را فراخوانی میکند تا پیشنهادها تولید شوند.
نکته: اگر این متغیر برای Business logic لازم نبود، همانطور که اکنون برای تولید پیشنهادهای کاربری لازم است، نباید به State holder سطح صفحه Hoist شود. در آن حالت باید در UI و نزدیکتر به تابع Composableای که به آن نیاز دارد تعریف و ذخیره شود.
suggestions همان Screen UI state است و از Compose UI با collect کردن از StateFlow مصرف میشود.
نکته: ممکن است یک Composable سطح صفحه هم یک ViewModel داشته باشد که دسترسی به Business logic را فراهم میکند و هم یک کلاس State holder ساده داشته باشد که UI logic و UI element state را مدیریت میکند.
نکته احتیاطی
برای بعضی UI element stateهای Compose، Hoist کردن به ViewModel ممکن است نیازمند ملاحظات خاصی باشد. برای مثال، بعضی State holderهای UI elementهای Compose متدهایی برای تغییر State ارائه میدهند. بعضی از این متدها ممکن است suspend functionهایی باشند که Animation اجرا میکنند. اگر این suspend functionها را از یک CoroutineScope که به Composition محدود نیست فراخوانی کنید، ممکن است Exception ایجاد کنند.
فرض کنید محتوای App drawer پویا است و لازم است پس از بسته شدن Drawer، آن را از لایه Data دریافت و Refresh کنید. ممکن است بخواهید Drawer state را به ViewModel Hoist کنید تا بتوانید هم UI logic و هم Business logic را روی این عنصر از مالک State فراخوانی کنید.
اما فراخوانی متد close() مربوط به DrawerState با استفاده از viewModelScope از Compose UI باعث یک Runtime exception از نوع IllegalStateException میشود، با پیامی که میگوید:
a MonotonicFrameClock is not available in this CoroutineContext
برای رفع این مشکل، از یک CoroutineScope محدود به Composition استفاده کنید. این Scope یک MonotonicFrameClock در CoroutineContext فراهم میکند که برای کار کردن suspend functionها لازم است.
هشدار: فراخوانی بعضی suspend functionهایی که از UI element state در Compose ارائه میشوند و Animation اجرا میکنند، اگر از CoroutineScopeای فراخوانی شوند که به Composition محدود نیست، Exception ایجاد میکند. برای مثال، LazyListState.animateScrollTo() و DrawerState.close().
برای رفع این Crash، CoroutineContext مربوط به Coroutine در ViewModel را به Contextای تغییر دهید که به Composition محدود است. این کار میتواند به شکل زیر باشد:
class ConversationViewModel(/*...*/) : ViewModel() {
val drawerState = DrawerState(initialValue = DrawerValue.Closed)
private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()
fun closeDrawer(uiScope: CoroutineScope) {
viewModelScope.launch {
withContext(uiScope.coroutineContext) { // بهجای Context پیشفرض استفاده شود
drawerState.close()
}
// دریافت محتوای drawer و بهروزرسانی State
_drawerContent.update { content }
}
}
}
// داخل Compose
@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {
val scope = rememberCoroutineScope()
ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}
این محتوا کاملا رایگان توسط تیم کدلپر ترجمه شده و در اختیار شما کاربران عزیز قرار گرفته است، هر گونه کپی برداری برای مقاصد غیر رایگان و بدون ذکر منبع، مورد پیگیری قانونی قرار میگیرد.
ترجمه شده از منبع: منبع مستندات