تولید دولتی UI

رابط‌های کاربری مدرن به ندرت ایستا هستند. وضعیت رابط کاربری زمانی تغییر می‌کند که کاربر با رابط کاربری تعامل داشته باشد یا زمانی که برنامه نیاز به نمایش داده‌های جدید داشته باشد.

این سند دستورالعمل‌هایی را برای تولید و مدیریت وضعیت رابط کاربری (UI state) تعیین می‌کند. در پایان آن شما باید:

  • بدانید که برای تولید وضعیت رابط کاربری باید از چه APIهایی استفاده کنید. این بستگی به ماهیت منابع تغییر وضعیت موجود در نگهدارنده‌های وضعیت شما دارد، که از اصول جریان داده یک‌طرفه پیروی می‌کند.
  • بدانید که چگونه باید تولید وضعیت رابط کاربری را محدود کنید تا از منابع سیستم آگاه باشید.
  • بدانید که چگونه باید وضعیت رابط کاربری را برای استفاده توسط رابط کاربری نمایش دهید.

اساساً، تولید وضعیت، اعمال تدریجی این تغییرات در وضعیت رابط کاربری است. وضعیت همیشه وجود دارد و در نتیجه رویدادها تغییر می‌کند. تفاوت‌های بین رویدادها و وضعیت در جدول زیر خلاصه شده است:

رویدادها ایالت
گذرا، غیرقابل پیش‌بینی، و برای یک دوره محدود وجود دارند. همیشه وجود دارد.
نهاده‌های تولید دولتی. خروجی تولید دولتی.
محصول رابط کاربری یا منابع دیگر. توسط رابط کاربری مصرف می‌شود.

یک یادآوری عالی که موارد فوق را خلاصه می‌کند این است که حالت، رویدادی است که اتفاق می‌افتد . نمودار زیر به تجسم تغییرات حالت در حین وقوع رویدادها در یک جدول زمانی کمک می‌کند. هر رویداد توسط دارنده حالت مناسب پردازش می‌شود و منجر به تغییر حالت می‌شود:

رویدادها در مقابل ایالت
شکل ۱ : رویدادها باعث تغییر وضعیت می‌شوند

رویدادها می‌توانند از موارد زیر ناشی شوند:

  • کاربران : همانطور که با رابط کاربری برنامه تعامل دارند.
  • منابع دیگر تغییر وضعیت : APIهایی که داده‌های برنامه را از رابط کاربری، دامنه یا لایه‌های داده مانند رویدادهای timeout مربوط به snackbar، موارد استفاده یا مخازن ارائه می‌دهند.

خط تولید وضعیت UI

تولید وضعیت در برنامه‌های اندروید را می‌توان به عنوان یک خط لوله پردازشی در نظر گرفت که شامل موارد زیر است:

  • ورودی‌ها : منابع تغییر وضعیت. آنها ممکن است عبارتند از:
    • محلی برای لایه رابط کاربری: این موارد می‌توانند رویدادهای کاربری مانند وارد کردن عنوان برای یک «کار» در یک برنامه مدیریت وظیفه یا APIهایی باشند که دسترسی به منطق رابط کاربری را فراهم می‌کنند که باعث ایجاد تغییرات در وضعیت رابط کاربری می‌شود. به عنوان مثال، فراخوانی متد open در DrawerState در Jetpack Compose.
    • منابع خارجی برای لایه رابط کاربری: اینها منابعی از دامنه یا لایه‌های داده هستند که باعث تغییر در وضعیت رابط کاربری می‌شوند. برای مثال، اخباری که بارگذاری آنها از NewsRepository به پایان رسیده است یا رویدادهای دیگر.
    • مخلوطی از همه موارد فوق.
  • دارندگان وضعیت : انواعی که منطق کسب‌وکار و/یا منطق رابط کاربری را بر منابع تغییر وضعیت اعمال می‌کنند و رویدادهای کاربر را برای تولید وضعیت رابط کاربری پردازش می‌کنند.
  • خروجی : حالت رابط کاربری که برنامه می‌تواند برای ارائه اطلاعات مورد نیاز کاربران، رندر کند.
خط تولید دولتی
شکل ۲ : خط تولید ایالتی

API های تولید دولتی

بسته به اینکه در چه مرحله‌ای از خط تولید هستید، دو API اصلی در تولید وضعیت (state production) استفاده می‌شوند:

مرحله خط لوله رابط برنامه‌نویسی کاربردی
ورودی شما باید از APIهای ناهمزمان برای انجام کارها خارج از نخ رابط کاربری استفاده کنید تا از بروز مشکلات رابط کاربری جلوگیری شود. برای مثال، Coroutineها یا Flowها در کاتلین و RxJava یا callbackها در زبان برنامه‌نویسی جاوا.
خروجی شما باید از APIهای نگهدارنده داده قابل مشاهده برای نامعتبر کردن و رندر مجدد رابط کاربری هنگام تغییر وضعیت استفاده کنید. به عنوان مثال، StateFlow، Compose State یا LiveData. نگهدارنده‌های داده قابل مشاهده تضمین می‌کنند که رابط کاربری همیشه یک وضعیت رابط کاربری برای نمایش روی صفحه دارد.

از بین این دو، انتخاب API ناهمزمان برای ورودی، تأثیر بیشتری بر ماهیت خط تولید حالت نسبت به انتخاب API قابل مشاهده برای خروجی دارد. دلیل این امر این است که ورودی‌ها نوع پردازشی را که ممکن است روی خط تولید اعمال شود، تعیین می‌کنند .

مونتاژ خط لوله تولید دولتی

بخش‌های بعدی تکنیک‌های تولید وضعیت را که برای ورودی‌های مختلف مناسب‌تر هستند و APIهای خروجی که با آنها مطابقت دارند، پوشش می‌دهد. هر خط تولید وضعیت ترکیبی از ورودی‌ها و خروجی‌ها است و باید:

  • آگاه از چرخه حیات : در مواردی که رابط کاربری قابل مشاهده یا فعال نیست، خط تولید وضعیت نباید هیچ منبعی را مصرف کند، مگر اینکه صریحاً لازم باشد.
  • سهولت استفاده : رابط کاربری باید بتواند به راحتی حالت رابط کاربری تولید شده را رندر کند. ملاحظات مربوط به خروجی خط تولید حالت در رابط‌های برنامه‌نویسی کاربردی مختلف View مانند سیستم View یا Jetpack Compose متفاوت خواهد بود.

ورودی‌ها در خطوط تولید ایالتی

ورودی‌ها در یک خط تولید وضعیت ممکن است منابع تغییر وضعیت خود را از طریق موارد زیر فراهم کنند:

  • عملیات‌های تک‌مرحله‌ای که می‌توانند همزمان یا ناهمزمان باشند، برای مثال فراخوانی توابع برای suspend .
  • APIهای جریان، برای مثال Flows .
  • همه موارد فوق.

بخش‌های بعدی نحوه‌ی مونتاژ یک خط تولید وضعیت برای هر یک از ورودی‌های فوق را پوشش می‌دهند.

APIهای تک‌مرحله‌ای به عنوان منابع تغییر وضعیت

از API MutableStateFlow به عنوان یک ظرف قابل مشاهده و تغییرپذیر از وضعیت استفاده کنید. در برنامه‌های Jetpack Compose، می‌توانید mutableStateOf نیز در نظر بگیرید، به خصوص هنگام کار با APIهای متنی Compose . هر دو API روش‌هایی را ارائه می‌دهند که امکان به‌روزرسانی‌های اتمی ایمن برای مقادیری که میزبانی می‌کنند را فراهم می‌کنند، چه به‌روزرسانی‌ها همزمان باشند و چه ناهمزمان.

برای مثال، به‌روزرسانی‌های وضعیت را در یک برنامه‌ی ساده‌ی پرتاب تاس در نظر بگیرید. هر پرتاب تاس از طرف کاربر، متد Random.nextInt() همزمان را فراخوانی می‌کند و نتیجه در وضعیت رابط کاربری نوشته می‌شود.

جریان حالت

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

حالت نوشتن

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

    // Called from the UI
    fun rollDice() {
        _uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.numberOfRolls = _uiState.numberOfRolls + 1
    }
}

تغییر وضعیت رابط کاربری از فراخوانی‌های ناهمزمان

برای تغییرات وضعیتی که نیاز به نتیجه‌ای ناهمزمان دارند، یک Coroutine را در CoroutineScope مناسب اجرا کنید. این کار به برنامه اجازه می‌دهد تا هنگام لغو CoroutineScope کار را کنار بگذارد. سپس دارنده وضعیت، نتیجه فراخوانی متد suspend را در API قابل مشاهده‌ای که برای نمایش وضعیت UI استفاده می‌شود، می‌نویسد.

برای مثال، AddEditTaskViewModel در نمونه معماری در نظر بگیرید. وقتی متد saveTask() در حال تعلیق، یک وظیفه را به صورت ناهمگام ذخیره می‌کند، متد update در MutableStateFlow تغییر حالت را به حالت رابط کاربری (UI) منتقل می‌کند.

جریان حالت

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableStateFlow(AddEditTaskUiState())
   val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

حالت نوشتن

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

تغییر وضعیت رابط کاربری از نخ‌های پس‌زمینه

ترجیح داده می‌شود که Coroutineها را برای تولید وضعیت UI روی توزیع‌کننده اصلی اجرا کنید. یعنی، خارج از بلوک withContext در قطعه کد زیر. با این حال، اگر نیاز دارید وضعیت UI را در یک زمینه پس‌زمینه متفاوت به‌روزرسانی کنید، می‌توانید این کار را با استفاده از APIهای زیر انجام دهید:

  • از متد withContext برای اجرای Coroutineها در یک context همزمان متفاوت استفاده کنید.
  • هنگام استفاده از MutableStateFlow ، طبق معمول از متد update استفاده کنید.
  • هنگام استفاده از Compose State، از Snapshot.withMutableSnapshot برای تضمین به‌روزرسانی‌های اتمی State در زمینه همزمان استفاده کنید.

برای مثال، در قطعه کد DiceRollViewModel زیر فرض کنید که SlowRandom.nextInt() یک تابع suspend با محاسبات فشرده است که باید از یک Coroutine متصل به CPU فراخوانی شود.

جریان حالت

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

حالت نوشتن

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

APIهای جریانی به عنوان منابع تغییر وضعیت

برای منابع تغییر وضعیت که در طول زمان مقادیر متعددی را در جریان‌ها تولید می‌کنند، تجمیع خروجی‌های همه منابع در یک کل منسجم، رویکردی ساده برای تولید وضعیت است.

هنگام استفاده از Kotlin Flows، می‌توانید با استفاده از تابع combine به این هدف دست یابید. نمونه‌ای از این مورد را می‌توانید در نمونه "Now in Android" در InterestsViewModel مشاهده کنید:

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

استفاده از عملگر stateIn برای ایجاد StateFlows به رابط کاربری کنترل دقیق‌تری بر فعالیت خط تولید حالت می‌دهد، زیرا ممکن است فقط زمانی که رابط کاربری قابل مشاهده است، لازم باشد فعال باشد.

  • اگر قرار است pipeline فقط زمانی فعال باشد که رابط کاربری هنگام جمع‌آوری جریان به شیوه‌ای آگاه از چرخه حیات قابل مشاهده باشد، از SharingStarted.WhileSubscribed() استفاده کنید.
  • اگر قرار است خط لوله تا زمانی که کاربر می‌تواند به رابط کاربری بازگردد، فعال باشد، یعنی رابط کاربری در backstack یا در تب دیگری خارج از صفحه باشد، SharingStarted.Lazily استفاده کنید.

در مواردی که تجمیع منابع حالت مبتنی بر جریان امکان‌پذیر نباشد، APIهای جریان مانند Kotlin Flows مجموعه‌ای غنی از تبدیل‌ها مانند ادغام ، مسطح‌سازی و غیره را برای کمک به پردازش جریان‌ها به حالت رابط کاربری ارائه می‌دهند.

APIهای تک‌مرحله‌ای و جریانی به عنوان منابع تغییر وضعیت

در حالتی که خط تولید حالت به هر دو فراخوانی‌های تک‌مرحله‌ای و جریان‌ها به عنوان منابع تغییر حالت وابسته باشد، جریان‌ها محدودیت تعیین‌کننده هستند. بنابراین، فراخوانی‌های تک‌مرحله‌ای را به APIهای جریان‌ها تبدیل کنید، یا خروجی آنها را به جریان‌ها لوله‌کشی کنید و پردازش را همانطور که در بخش جریان‌ها در بالا توضیح داده شد، از سر بگیرید.

در مورد جریان‌ها، این معمولاً به معنای ایجاد یک یا چند نمونه خصوصی MutableStateFlow پشتیبان برای انتشار تغییرات حالت است. همچنین می‌توانید جریان‌های snapshot را از حالت Compose ایجاد کنید .

TaskDetailViewModel را از مخزن architecture-samples زیر در نظر بگیرید:

جریان حالت

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

حالت نوشتن

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

انواع خروجی در خطوط تولید ایالتی

انتخاب API خروجی برای وضعیت رابط کاربری و ماهیت ارائه آن تا حد زیادی به API مورد استفاده برنامه شما برای رندر رابط کاربری بستگی دارد. در برنامه‌های اندروید، می‌توانید از Views یا Jetpack Compose استفاده کنید. ملاحظاتی که در اینجا باید در نظر گرفته شوند عبارتند از:

جدول زیر خلاصه می‌کند که برای هر ورودی و مصرف‌کننده‌ی مشخص، از چه APIهایی باید برای خط تولید حالت خود استفاده کنید:

ورودی مصرف کننده خروجی
API های تک مرحله ای بازدیدها StateFlow یا LiveData
API های تک مرحله ای نوشتن StateFlow یا Compose State
APIهای جریان بازدیدها StateFlow یا LiveData
APIهای جریان نوشتن StateFlow
APIهای تک‌مرحله‌ای و جریانی بازدیدها StateFlow یا LiveData
APIهای تک‌مرحله‌ای و جریانی نوشتن StateFlow

مقداردهی اولیه خط تولید ایالتی

مقداردهی اولیه‌ی خطوط تولید وضعیت شامل تنظیم شرایط اولیه برای اجرای خط تولید است. این ممکن است شامل ارائه مقادیر ورودی اولیه‌ای باشد که برای شروع خط تولید حیاتی هستند، به عنوان مثال یک id برای نمای جزئیات یک مقاله خبری یا شروع یک بارگذاری ناهمزمان.

شما باید در صورت امکان، خط تولید حالت را به صورت تنبل (lazy) مقداردهی اولیه کنید تا منابع سیستم حفظ شود. در عمل، این اغلب به معنای انتظار تا زمانی است که یک مصرف‌کننده برای خروجی وجود داشته باشد. APIهای Flow APIs) این امکان را با آرگومان started در متد stateIn فراهم می‌کنند. در مواردی که این مورد قابل اجرا نیست، یک تابع idempotent initialize() تعریف کنید تا خط تولید حالت را به طور صریح شروع کند، همانطور که در قطعه کد زیر نشان داده شده است:

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

نمونه‌ها

نمونه‌های گوگل زیر، تولید حالت در لایه رابط کاربری را نشان می‌دهند. برای مشاهده این راهنمایی در عمل، آنها را بررسی کنید:

{% کلمه به کلمه %} {% فعل کمکی %} {% کلمه به کلمه %} {% فعل کمکی %}