مدیریت وضعیت در کاتلین مولتی‌پلتفرم: پیاده‌سازی Flow و StateFlow در معماری تمیز

Gemini
Gemini
2026/09/06
زمان مطالعه زمان مطالعه: 18 دقیقه
43 بازدید
مدیریت وضعیت در کاتلین مولتی‌پلتفرم: پیاده‌سازی Flow و StateFlow در معماری تمیز

بحران مدیریت وضعیت در کاتلین مولتی‌پلتفرم و چرا معماری تمیز تنها نجات‌دهنده است

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

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

// اشتباه رایج: وابستگی UI به متغیرهای پلتفرمی
class UserProfileViewModel {
    var userState: String = "Guest" // با چرخش صفحه این مقدار ریست می‌شود!
}

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

اشتباه رایج: نگهداری وضعیت‌های بحرانی مثل توکن احراز هویت در متغیرهای ساده اکتیویتی یا ویومدل‌های پلتفرمی باعث کرش‌های ناگهانی در کاتلین مولتی‌پلتفرم می‌شود.

مفهوم پایه Flow: درک جریان داده‌های سرد (Cold Streams) در ایزوله‌ترین حالت

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

راه حل غریزی یک توسعه‌دهنده تازه‌کار استفاده از Callbackهای پیچیده و تو در تو است. این روش خوانایی کد را کاملاً از بین می‌برد و منجر به پدیده معروف Callback Hell می‌شود که نگهداری و دیباگ اپلیکیشن را به یک کابوس تبدیل می‌کند.

انتخاب مهندسی ما استفاده از Flow سرد در کاتلین است تا داده‌ها به صورت تنبل (Lazy) تولید شوند. این ساختار تا زمانی که هیچ مصرف‌کننده‌ای وجود نداشته باشد، هیچ منبعی را مصرف نمی‌کند و به محض اتصال Collector، شروع به انتشار داده می‌کند.

مسیر حرکت داده در این ساختار به شکل زیر است:

Producer -> emit() -> Intermediate Operators -> Collector

بیایید ساختار یک تولیدکننده ساده را بررسی کنیم که اعداد را به صورت جریانی تولید می‌کند:

val numberFlow: Flow<Int> = flow {
    for (i in 1..3) {
        delay(1000)
        emit(i)
    }
}

متد delay(1000) یعنی ترد اصلی مسدود نمی‌شود و تولید داده با تاخیر یک ثانیه‌ای انجام می‌شود تا UI زمان کافی برای رندر داشته باشد. مقدار 1000 میلی‌ثانیه برای شبیه‌سازی پاسخ شبکه انتخاب شده است؛ اگر این مقدار را کاهش دهیم، فشار روی پردازنده بالا می‌رود و اگر زیاد کنیم، تجربه کاربری کند می‌شود. متد emit(i) نیز مقدار جدید را به سمت پایین‌دست پرتاب می‌کند.

نکته کاربردی: جریان‌های سرد یا همان Cold Streams تا زمانی که متد collect() فراخوانی نشود، هیچ کدی را اجرا نمی‌کنند. این یعنی تعریف Flow هیچ هزینه‌ای در حافظه ندارد.

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

numberFlow
    .map { value -> value * 2 }
    .collect { result -> 
        println("Received: $result")
    }

عملگر map هر مقدار دریافتی را دگرگون می‌کند و به مرحله بعد می‌فرستد. در اینجا مقدار 2 ضریب تبدیل ماست که خروجی‌های 2، 4 و 6 را تولید می‌کند.

خروجی واقعی اجرای این تکه کد در کنسول به شکل زیر خواهد بود:

Received: 2
Received: 4
Received: 6

بررسی این خروجی نشان می‌دهد که داده‌ها با فاصله زمانی یک ثانیه و به ترتیب دقیق وارد مصرف‌کننده شده‌اند. این نظم ساختاری از تداخل تردها جلوگیری می‌کند و پایداری اپلیکیشن را تضمین می‌کند.

گسترش قابلیت‌ها: تبدیل جریان‌های سرد به جریان‌های گرم با StateFlow

وقتی کاربر صفحه اپلیکیشن را می‌چرخاند یا چرخش صفحه (Configuration Change) رخ می‌دهد، لایه UI از نو ساخته می‌شود و نیاز دارد به داده‌های قبلی گوش دهد. اگر از جریان‌های معمولی استفاده کنیم، با هر بار اتصال مجدد (Collection)، کل منطق تولید داده از صفر اجرا می‌شود و این برای مدیریت وضعیت UI یک فاجعه تمام‌عیار است.

راه حلی که یک برنامه‌نویس تازه‌کار به طور غریزی انتخاب می‌کند، ذخیره کردن مقادیر در متغیرهای معمولی یا LiveDataهای قدیمی پلتفرم اندروید است. اما مشکل اینجاست که LiveData منطق کاتلین مولتی‌پلتفرم را نقض می‌کند و در پلتفرم‌های دسکتاپ یا iOS کاملاً بی‌استفاده است. سادگی ظاهر این روش باعث می‌شود تداخل تردهای ناخواسته و از دست رفتن وضعیت در لحظه تغییرات سریع UI به یک باگ همیشگی تبدیل شود.

انتخاب مهندسی ما استفاده از StateFlow است؛ یک جریان گرم که همیشه آخرین مقدار وضعیت را در خود نگه می‌دارد و به محض اتصال هر مصرف‌کننده جدید، آن مقدار را فوراً به او تحویل می‌دهد. این ساختار تضمین می‌کند که داده‌ها حتی بدون حضور فعال مصرف‌کننده زنده بمانند و حافظه سیستم بی‌جهت درگیر محاسبه مجدد نشود.

مسیری که داده در این ساختار طی می‌کند به این شکل است:

MutableStateFlow (تولیدکننده) --> StateFlow (نگهبان آخرین مقدار) --> Collector (مصرف‌کننده در UI)

برای درک بهتر رفتار این ساختار، اجازه دهید یک نمونه کد ساده برای مقداردهی اولیه و انتشار وضعیت بررسی کنیم:

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

fun loadData() {
    _uiState.value = UiState.Success(data = "بارگذاری موفق")
}

در قطعه کد بالا، مقدار اولیه روی حالت Loading تنظیم شده است تا UI بلافاصله وضعیت انتظار را نشان دهد. استفاده از متد asStateFlow() دسترسی نوشتن به جریان را محدود می‌کند و لایه ViewModel تنها اجازه خواندن وضعیت را به لایه UI می‌دهد.

نکته کاربردی: هرگز وضعیت‌های موقت مثل رویدادهای یک‌باره (One-time Events) مانند نمایش اسنک‌بار را درون StateFlow نگهداری نکنید، چون مقدار نهایی در جریان باقی می‌ماند و با هر چرخش صفحه، پیام دوباره به کاربر نمایش داده می‌شود.

حالا بیایید ببینیم مصرف‌کننده چطور این مقادیر را دریافت می‌کند و خروجی واقعی آن به چه شکل در سیستم ثبت می‌شود:

viewModelScope.launch {
    uiState.collect { state ->
        println("وضعیت جدید دریافتی: $state")
    }
}

وقتی این بلوک اجرا می‌شود، خروجی کنسول بلافاصله وضعیت فعلی را چاپ می‌کند:

وضعیت جدید دریافتی: UiState.Loading
وضعیت جدید دریافتی: UiState.Success(data=بارگذاری موفق)

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

طراحی لایه ViewModel مشترک در کاتلین مولتی‌پلتفرم بدون وابستگی به اندروید

وقتی اپلیکیشنی را برای چند پلتفرم مختلف توسعه می‌دهیم، بزرگ‌ترین خطای معماری این است که منطق بیزینس و نگهداری وضعیت (State) را به لایه‌های وابسته به سیستم‌عامل گره بزنیم. اگر کدهای لایه نمایش ما به کتابخانه‌های خاص اندروید مثل androidx.lifecycle.ViewModel وابسته شوند، عملاً امکان استفاده مجدد از آن‌ها در iOS یا دسکتاپ را از دست می‌دهیم و کل فلسفه کاتلین مولتی‌پلتفرم زیر سؤال می‌رود.

راه حل ساده‌ای که شاید به ذهن یک برنامه‌نویس تازه‌کار برسد، این است که کلاس‌های پکیج androidx.lifecycle را مستقیماً داخل ماژول مشترک (commonMain) ایمپورت کند. اما این کار در عمل به یک بن‌بست معماری تبدیل می‌شود، چون کامپایلر در پلتفرم‌های دیگر به‌دلیل عدم دسترسی به وابستگی‌های اندرویدی دچار خطا می‌شود و ساختار پروژه ما ناپایدار خواهد شد.

انتخاب مهندسی درست این است که یک ساختار مستقل از پلتفرم برای مدیریت چرخه عمر و جریان داده‌ها در ماژول مشترک پیاده‌سازی کنیم. ما با ترکیب CoroutineScope و StateFlow یک کلاس پایه برای مدیریت وضعیت می‌سازیم که دقیقاً همان وظایف یک ViewModel سنتی را بدون وابستگی به کدهای سیستمی اندروید انجام می‌دهد.

برای پیاده‌سازی این ساختار مستقل، از یک کلاس پایه استفاده می‌کنیم که مدیریت تردها و کنترل چرخه عمر عملیات‌های ناهمگام را بر عهده دارد.

abstract class CommonViewModel {
    protected val viewModelScope = CoroutineScope(Dispatchers.Main + SupervisorJob())

    fun clear() {
        viewModelScope.cancel()
    }
}

مقدار Dispatchers.Main تضمین می‌کند که تغییرات وضعیت همیشه روی ترد اصلی اجرا شوند تا لایه UI بدون مشکل آن‌ها را رندر کند. همچنین استفاده از SupervisorJob باعث می‌شود اگر یکی از درخواست‌های شبکه یا پردازش‌های موازی با خطا مواجه شد، کل اسکوپ بسته نشود و بقیه بخش‌های اپلیکیشن به کار خود ادامه دهند.

حالا بیایید این ساختار را با اضافه کردن یک StateFlow کامل‌تر کنیم تا داده‌ها به شکل واکنشی به دست لایه نمایش برسند.

abstract class CommonViewModel<T>(initialState: T) {
    protected val viewModelScope = CoroutineScope(Dispatchers.Main + SupervisorJob())

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

    protected fun updateState(update: (T) -> T) {
        _uiState.value = update(_uiState.value)
    }

    open fun onCleared() {
        viewModelScope.cancel()
    }
}

تابع updateState به ما اجازه می‌دهد وضعیت فعلی را دریافت کرده و نسخه جدیدی از آن را به صورت ایمن جایگزین کنیم. این الگو از بروز تداخل در تغییر همزمان داده‌ها جلوگیری می‌کند و جریان داده‌ها را کاملاً قابل پیش‌بینی نگه می‌دارد.

جریان چرخه داده و مدیریت وضعیت در این معماری مستقل به شکل زیر عمل می‌کند:

  • درخواست کاربر از لایه UI به متدهای ViewModel می‌رسد.
  • پردازش‌های سنگین یا درخواست‌های شبکه درون viewModelScope اجرا می‌شوند.
  • تغییرات جدید از طریق MutableStateFlow اعمال می‌شوند.
  • لایه نمایش در هر پلتفرمی (اندروید، iOS یا دسکتاپ) تغییرات را دریافت و خود را به‌روز می‌کند.

نکته کاربردی: حتماً متد پاکسازی اسکوپ را در زمان مناسب (مثلاً وقتی صفحه در iOS بسته می‌شود یا در دسکتاپ از بین می‌رود) فراخوانی کنید تا از نشت حافظه (Memory Leak) جلوگیری شود.

موفق شدیم لایه ViewModel را کاملاً مستقل از پلتفرم اندروید پیاده‌سازی کنیم و منطق وضعیت را درون ماژول مشترک قرار دهیم.

پیاده‌سازی Use Caseها و مدیریت رویدادها در معماری تمیز

وقتی منطق بیزینس اپلیکیشن را مستقیماً در ViewModel می‌نویسیم، با بزرگ شدن پروژه، ویومدل ما تبدیل به یک کلاس قطور و پر از خطاهای پیش‌بینی‌نشده می‌شود که تست کردن آن تقریباً غیرممکن است. بیایید تصور کنیم فردا قرار است نحوه محاسبه تخفیف را تغییر دهیم؛ اگر این منطق لابلای کدهای مدیریت UI ویومدل گم شده باشد، هر تغییری ممکن است کل صفحه را از کار بیندازد.

راهکار ابتدایی و طبیعی که بسیاری از برنامه‌نویسان تازه‌کار می‌روند این است که تمام درخواست‌های شبکه و پردازش داده‌ها را مستقیماً در ویومدل فراخوانی کنند. این کار باعث می‌شود ویومدل شما به لایه داده گره بخورد و نتوانید آن را به صورت مستقل تست واحد (Unit Test) کنید.

انتخاب مهندسی درست این است که منطق بیزینس را کاملاً از ویومدل جدا کنیم و به Use Caseهای ایزوله منتقل کنیم. این Use Caseها جریان داده‌ها را به صورت Flow از لایه داده دریافت می‌کنند، عملیات خاص مورد نظر را روی آن انجام می‌دهند و نتیجه را تمیز تحویل می‌دهند.

[Data Layer] ---> (Flow) ---> [Use Case] ---> (Processed Flow) ---> [ViewModel] ---> [UI]

بیایید یک Use Case ساده برای دریافت لیست کاربران پیاده‌سازی کنیم تا ببینیم چطور کار می‌کند:

class GetActiveUsersUseCase(
    private val userRepository: UserRepository
) {
    operator fun invoke(): Flow<List<User>> {
        return userRepository.getUsers()
            .map { users -> users.filter { it.isActive } }
    }
}

در این کد، متد invoke به ما اجازه می‌دهد کلاس را مانند یک تابع معمولی فراخوانی کنیم. فیلتر کردن روی مقدار isActive یعنی ما فقط کاربران فعال را می‌خواهیم و کاربران غیرفعال را عمداً حذف می‌کنیم تا UI درگیر داده‌های اضافه نشود.

حالا بیایید ببینیم همین Use Case را چطور می‌توانیم با یک پارامتر ورودی (مثل محدود کردن تعداد نتایج) تغییر دهیم:

class GetLimitedUsersUseCase(
    private val userRepository: UserRepository
) {
    operator fun invoke(limitCount: Int): Flow<List<User>> {
        return userRepository.getUsers()
            .map { users -> users.take(limitCount) }
    }
}

مقدار limitCount مشخص می‌کند که دقیقاً چه تعداد از رکوردها را نیاز داریم؛ مثلاً عدد 10 یعنی فقط ده کاربر اول را بارگذاری کن تا مصرف حافظه و پهنای باند شبکه بهینه بماند.

نکته کاربردی: هر Use Case باید دقیقاً یک وظیفه مشخص (Single Responsibility) داشته باشد. اگر مشاهده کردید که یک Use Case دارد هم‌زمان هم عملیات پرداخت را انجام می‌دهد و هم پروفایل کاربر را به‌روزرسانی می‌کند، یعنی مرزهای معماری تمیز را رعایت نکرده‌اید و باید آن را به دو کلاس مجزا تقسیم کنید.

حالا که منطق بیزینس را ایزوله کردیم، ویومدل ما بسیار سبک می‌شود و تنها وظیفه دارد این Use Case را فراخوانی کند و نتیجه را در یک StateFlow قرار دهد تا لایه UI بتواند به سادگی به آن گوش دهد. این ساختار به ما کمک می‌کند که کدهایی کاملاً تست‌پذیر، تمیز و قابل نگهداری داشته باشیم.

مدیریت خطاهای ناگهانی و Stateهای بحران با اپراتورهای پیشرفته Flow

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

یک توسعه‌دهنده تازه‌کار معمولاً برای جلوگیری از این مشکلات، کل منطق دریافت داده درون ViewModel را داخل یک بلاک try-catch سنتی می‌پیچد. این روش شاید جلوی کرش کردن فوری را بگیرد، اما مشکل بزرگ اینجاست که بلوک try-catch جریان واکنشی یا همان Flow را کاملاً متوقف می‌کند. وقتی خطایی رخ می‌دهد و استثنا پرتاب می‌شود، کل کانال داده بسته می‌شود و دیگر هیچ آپدیت جدیدی از سمت سرور به دست ما نمی‌رسد. کاربر هر چقدر هم صفحه را رفرش کند، هیچ اتفاقی نمی‌افتد چون زنجیره داده مرده است.

به جای این رویکرد شکننده، از اپراتورهای تخصصی catch و retry در سطح زنجیره Flow استفاده می‌کنیم. این اپراتورها به ما اجازه می‌دهند خطا را در میانه راه بدون قطع کردن کل جریان به دام بیندازیم و سیستم را وادار به تلاش مجدد کنیم. با این کار، لایه UI هیچ‌وقت با یک جریان مرده مواجه نمی‌شود و همیشه به یک StateFlow فعال گوش می‌دهد.

برای درک بهتر رفتار این اپراتورها، بیایید مسیر حرکت داده و مدیریت خطا را در این ساختار بررسی کنیم:

  • درخواست اولیه: شروع جریان داده از منبع (مانند سرور یا دیتابیس)
  • بروز خطا: قطع اتصال یا خطای ۵۰۰ سرور در حین پردازش
  • مهار خطا: اپراتور catch خطا را اسکن کرده و به یک State بحران تبدیل می‌کند
  • تلاش مجدد: اپراتور retry درخواست را برای تعداد دفعات مشخصی تکرار می‌کند

برای پیاده‌سازی این مکانیزم، بیایید به کد زیر نگاه کنیم که چطور می‌توانیم خطاهای شبکه را بدون بستن کانال داده مدیریت کنیم:

fun fetchUserData(): Flow<UiState> = flow {
    emit(UiState.Loading)
    // فرض کنید این خط یک خطای شبکه پرتاب می‌کند
    val data = api.getUserProfile() 
    emit(UiState.Success(data))
}.catch { e ->
    emit(UiState.Error(e.localizedMessage ?: "خطای ناشناخته"))
}

اپراتور catch در اینجا خطای پرتاب شده را از بالادست دریافت می‌کند و اجازه نمی‌دهد اپلیکیشن کرش کند. مقدار UiState.Error به پایین‌دست ارسال می‌شود تا لایه UI بتواند پیام مناسب را به کاربر نشان دهد.

نکته کاربردی: اپراتور catch فقط خطاهایی را که در بالادست (Upstream) آن رخ می‌دهند می‌گیرد. اگر خطایی در پایین‌دست یعنی بعد از اپراتور catch رخ دهد، این بلاک آن را نخواهد گرفت. پس ترتیب قرارگیری اپراتورها در زنجیره بسیار حیاتی است.

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

fun fetchUserDataWithRetry(): Flow<UiState> = flow {
    emit(UiState.Loading)
    val data = api.getUserProfile()
    emit(UiState.Success(data))
}.retry(retries = 3) { cause ->
    // فقط در خطاهای شبکه تلاش مجدد انجام می‌دهیم
    cause is IOException 
}.catch { e ->
    emit(UiState.Error("عدم دسترسی به اینترنت پس از ۳ تلاش"))
}

در قطعه کد بالا، پارامتر retries = 3 تعیین می‌کند که اگر عملیات با شکست مواجه شد، حداکثر ۳ بار دیگر درخواست تکرار شود. مقدار ۳ یک تعادل استاندارد است؛ عدد کمتر ممکن است خطاهای گذرا را از دست بدهد و عدد بیشتر باعث معطل شدن بیش از حد کاربر و مصرف باتری می‌شود. شرط cause is IOException تضمین می‌کند که خطاهای منطقی بیزینس مثل دسترسی غیرمجاز تکرار نشوند، چون تکرار آن‌ها فایده‌ای ندارد.

در این بخش یاد گرفتیم چطور با استفاده از اپراتورهای پیشرفته Flow، خطاهای غیرمنتظره را مدیریت کنیم بدون اینکه جریان واکنشی داده قطع شود.

یکپارچه‌سازی نهایی: اتصال StateFlow مشترک به رابط کاربری SwiftUI و Jetpack Compose

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

یک توسعه‌دهنده تازه‌کار معمولاً تلاش می‌کند در هر پلتفرم یک StateListener دستی بنویسد و با کدهای تبدیل پیچیده، مقادیر دریافتی را در متغیرهای محلی State ذخیره کند. این روش ساده در ظاهر کار می‌کند، اما با کوچک‌ترین تغییر در چرخه حیات اکتیویتی یا ویو، منجر به نشت حافظه، به‌روزرسانی‌های اضافی رابط کاربری و از دست رفتن رخدادهای لحظه‌ای می‌شود.

انتخاب مهندسی درست این است که به جای دستکاری دستی چرخه حیات، از قابلیت‌های جمع‌آوری مستقیم با اکستنشن‌های مختص هر پلتفرم استفاده کنیم. در اندروید از collectAsStateWithLifecycle() در کامپوز بهره می‌بریم و در آی‌او‌اس جریان کاتلین را به یک ObservableObject در سوئیفت‌یوآی تبدیل می‌کنیم تا سیستم‌عامل به صورت خودکار مدیریت منابع را بر عهده بگیرد.

// لایه مشترک کاتلین
class UserViewModel {
    private val _uiState = MutableStateFlow(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
}

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

// پیاده‌سازی در اندروید با Jetpack Compose
@Composable
fun UserScreen(viewModel: UserViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    
    when (state) {
        is UserUiState.Loading -> CircularProgressIndicator()
        is UserUiState.Success -> Text(state.data)
        is UserUiState.Error -> Text("خطا در بارگذاری اطلاعات")
    }
}

متد collectAsStateWithLifecycle() به این دلیل انتخاب شده است که وقتی اپلیکیشن به پس‌زمینه می‌رود، جمع‌آوری جریان داده را به طور خودکار متوقف می‌کند تا از مصرف بی‌رویه باتری و پردازش اضافی جلوگیری شود. این رفتار استاندارد در اندروید مدرن است که جایگزین روش‌های سنتی مدیریت دستی Coroutine Scope شده است.

// پل ارتباطی در سویفت‌یوآی (SwiftUI)
class UserViewModelBridge: ObservableObject {
    private let iosViewModel = UserViewModel()
    @Published var state: UserUiState = UserUiState.Loading()

    init() {
        iosViewModel.uiState.watch { [weak self] uiState in
            if let uiState = uiState {
                self?.state = uiState
            }
        }
    }
}

در سمت آی‌او‌اس، کلاسی متصل به پروتکل ObservableObject می‌سازیم تا تغییرات وضعیت کاتلین را با استفاده از متد نظارتی watch دریافت کند و به ویژگی @Published منتقل نماید. استفاده از [weak self] در بلوک بسته (Closure) از ایجاد چرخه نگهداری و نشت حافظه جلوگیری می‌کند که برای پایداری اپلیکیشن‌های مقیاس‌پذیر حیاتی است.

نکته کاربردی: هرگز وضعیت‌های موقت رابط کاربری مثل خطای یک‌بار مصرف را در StateFlow اصلی نگه ندارید، چون با هر بار چرخش صفحه یا بازگشت به اکتیویتی، آن خطا دوباره به کاربر نمایش داده می‌شود. برای رویدادهای مستقل از وضعیت مثل نمایش پیامک موفقیت، از SharedFlow استفاده کنید.

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

بهینه‌سازی عملکرد، جلوگیری از نشت حافظه و الگوهای پیشرفته در محیط‌های مولتی‌پلتفرم

وقتی اپلیکیشن‌های مولتی‌پلتفرم را توسعه می‌دهیم، بزرگ‌ترین چالش ما پایداری حافظه در مرز بین کاتلین و سیستم‌عامل میزبان است. عدم پاکسازی صحیح CoroutineScopeها در ماژول‌های اشتراکی منجر به Memory Leak شدید روی iOS می‌شود؛ چون مدل مدیریت حافظه ARC در سویفت با سیستم زباله‌روب جاوا اسکریپت یا اندروید تفاوت اساسی دارد.

اگر یک توسعه‌دهنده تازه‌کار باشید، احتمالاً CoroutineScope را به صورت گلوبال یا بدون متصل کردن به چرخه حیات صفحه تعریف می‌کنید. این کار باعث می‌شود جابجایی بین صفحات باعث انباشتگی آبجکت‌ها در رم شود و سیستم‌عامل پس از چند کلیک، اپلیکیشن را به دلیل مصرف بالای مموری اصطلاحاً Kill کند.

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

class SharedViewModel : CoroutineScope {
    private val job = SupervisorJob()
    override val coroutineContext: CoroutineContext = Dispatchers.Main + job

    fun clear() {
        job.cancel()
    }
}

اشتباه رایج: استفاده از GlobalScope برای لانچ کردن کوروتین‌ها در ماژول‌های اشتراکی ممنوع است؛ زیرا هیچ‌وقت به طور خودکار کنسل نمی‌شود و تا پایان عمر اپلیکیشن در حافظه می‌ماند.

در کاتلین مولتی‌پلتفرم، مدل اجرای جریان‌ها نیازمند تعیین دقیق دیسپچرها بر اساس پلتفرم هدف است. دیسپچر پیش‌فرض ممکن است روی ترد اصلی iOS رفتاری متفاوت از اندروید نشان دهد که باعث خطای کرش ترد اصلی می‌شود.

class OptimizedRepository(
    private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun fetchData() = withContext(dispatcher) {
        // اجرای پردازش سنگین در پس‌زمینه
    }
}

برای پلتفرم‌هایی که مدیریت تردها در آن‌ها حساس‌تر است، استفاده از دیسپچر اختصاصی پایداری را تضمین می‌کند. مقدار Dispatchers.Default برای محاسبات سنگین CPU و Dispatchers.Main فقط برای به‌روزرسانی وضعیت رابط کاربری استفاده می‌شود.

در نهایت، با رعایت این نکات حافظه اپلیکیشن شما روی هر دو پلتفرم موبایل در امن‌ترین حالت ممکن قرار می‌گیرد.

این مطلب رو دوست داشتی؟ با دوستات به اشتراک بزار 😋