دمج Firebase مع تطبيقات Kotlin Multiplatform

دمج Firebase مع تطبيقات Kotlin Multiplatform

في السنوات الأخيرة أصبح Kotlin Multiplatform واحدًا من أكثر الخيارات إثارة للاهتمام لدى المطورين الذين يريدون تقليل تكرار العمل بين Android و iOS من دون التضحية بجودة التجربة على كل منصة. الفكرة هنا ليست مجرد “مشاركة بعض الدوال” بين المنصات، بل بناء طبقة منطق أعمال مشتركة يمكن الاعتماد عليها في أكثر من تطبيق، مع إبقاء الواجهات والتفاصيل الخاصة بكل منصة في مكانها الطبيعي. وعندما تدخل Firebase إلى المعادلة، يصبح السؤال أكثر واقعية: كيف نستفيد من خدمات مثل Authentication و Firestore و Crashlytics و Analytics و Cloud Messaging داخل مشروع Kotlin Multiplatform بطريقة عملية، نظيفة، وقابلة للصيانة على المدى الطويل؟

هذا المقال لا يقدّم لك وصفة سريعة فقط، بل يحاول أن يمشي معك خطوة خطوة، من الفكرة العامة إلى البنية المقترحة، ومن الخيارات المعمارية إلى الأكواد التي يمكن أن تبني عليها مباشرة. الفكرة الأساسية التي سنبني عليها هي أن دمج Firebase مع Kotlin Multiplatform ليس مجرد إضافة dependency وانتهى الأمر، بل هو قرار معماري ينعكس على طريقة تنظيم المشروع، واختيار منطق العمل المشترك، وكيفية التعامل مع المنصات المختلفة، وحتى طريقة كتابة الاختبارات. وإذا كنت قد بدأت مشروعًا جديدًا أو تفكر في تحويل جزء من تطبيقك الحالي إلى Multiplatform، فغالبًا ستجد في هذه الصفحات نقاطًا عملية تختصر عليك تجارب وتجارب كثيرة.

لماذا يجتمع Kotlin Multiplatform و Firebase في مشروع واحد؟

السبب الذي يجعل هذا الثنائي جذابًا جدًا هو أنه يجمع بين أمرين مختلفين لكنهما متكاملان. Kotlin Multiplatform يعطيك القدرة على مشاركة الكود، خصوصًا طبقة الـ domain والـ data والمنطق المتعلق بالشبكة والتخزين الداخلي وبعض قواعد العمل. أما Firebase فهو منصة خدمات جاهزة تساعدك على تجنب بناء كثير من القطع بنفسك، مثل تسجيل الدخول، قاعدة البيانات اللحظية أو السحابية، الإشعارات، التحليلات، تتبع الأعطال، وبعض قدرات الباك إند الخفيفة. وعندما تجمعهما معًا، فأنت في الحقيقة تحاول أن تبني تطبيقًا أو مجموعة تطبيقات تعتمد على منطق موحد ومصدر بيانات موحد مع أقل قدر ممكن من التكرار.

الجميل في هذه الفكرة أنها مناسبة جدًا للفرق الصغيرة والمتوسطة، وكذلك للمنتجات التي تريد الوصول السريع إلى السوق مع الحفاظ على إمكانية التوسع لاحقًا. بدل أن تكتب منطق تسجيل الدخول مرتين، وطبقة التواصل مع Firestore مرتين، ومعالجة الأخطاء مرتين، يمكنك أن تبني جزءًا كبيرًا من هذا في commonMain ثم تترك لكل منصة فقط ما يتعلق بواجهتها أو ببعض الأجزاء التي تحتاج تكاملًا مباشرًا مع النظام. هذا لا يوفر الوقت فقط، بل يخفف أيضًا من تضارب السلوك بين Android و iOS ويجعل إصلاح الأخطاء أسهل، لأنك غالبًا ستصلح المشكلة مرة واحدة بدل أن تطاردها في أكثر من مكان.

لكن هناك جانب مهم جدًا ينبغي أن نكون صريحين فيه: Firebase في عالم Kotlin Multiplatform ليس دائمًا “تركيبًا مباشرًا” مثل بعض الأدوات الأخرى. أحيانًا ستستخدم مكتبات متعددة المنصات جاهزة، وأحيانًا ستلجأ إلى غلاف مشترك يلف خدمات Firebase الأصلية على كل منصة، وأحيانًا ستبقي بعض الخدمات في الطبقات الخاصة بكل منصة لأن هذا يكون أوضح وأبسط. ولهذا السبب، اختيار الطريقة المناسبة يبدأ من فهم نوع الخدمة المطلوبة، وليس من الحماس لمجرد أن المشروع Multiplatform.

الصورة الكبيرة: كيف نفكر في المعمارية؟

قبل كتابة أي سطر كود، من الأفضل أن نتخيل شكل المشروع. في Kotlin Multiplatform عادةً يكون لديك shared module يحتوي على المنطق المشترك، ثم لديك Android App و iOS App وكل منهما يستهلك ذلك المنطق. أما Firebase فبعض خدماته يمكن أن تعيش داخل هذا الـ shared module، وبعضها قد يحتاج طبقة platform-specific. والقرار هنا يعتمد على نوع الخدمة وعلى مستوى النضج الذي تريد الوصول إليه.

على سبيل المثال، Authentication غالبًا يمكن جعله جزءًا قريبًا جدًا من الكود المشترك، لأنك تريد دوال مثل login, register, logout, observeAuthState أن تكون متاحة للمنطق المشترك والواجهات في كل منصة. Firestore أيضًا يمكن التعامل معه بطريقة مشتركة في طبقة repository إذا كانت المكتبة التي تستخدمها تدعمه بشكل جيد. أما Crashlytics و Messaging فقد تفضل أحيانًا التعامل معهما بشكل platform-specific، خاصة إذا أردت إمكانيات تكامل أعمق مع النظام أو خصائص تعتمد على المنصة بشكل مباشر. هذا لا يعني أن الخدمة غير مناسبة لـ KMP، بل يعني أن بعض خدمات Firebase تعيش أفضل عندما يتم عزلها في طبقة واضحة بدل إجبارها على شكل واحد لا يناسبها.

وأفضل نصيحة هنا هي أن تتعامل مع Firebase كمنصة خدمات، لا ككتلة واحدة. بمعنى أن تسأل نفسك قبل كل تكامل: هل هذه الخدمة يحتاجها المنطق المشترك فعلًا؟ هل أريد مجرد واجهة موحدة لها؟ أم أن المنصة نفسها يجب أن تتولى جزءًا من السلوك؟ هذه الأسئلة البسيطة تمنع كثيرًا من التعقيد الذي يظهر لاحقًا عندما يكبر المشروع.

ما الذي يمكن وضعه في shared code وما الذي يبقى platform-specific؟

هذا السؤال من أهم الأسئلة في أي مشروع Multiplatform، لأنه يحدد ملامح البنية منذ البداية. القاعدة العملية التي تنفع كثيرًا هي: شارك ما هو “منطقي” أو “قابل لإعادة الاستخدام” أو “مرتبط بقواعد العمل”، واترك ما هو “مرتبط بتفاصيل المنصة” في مكانه الطبيعي.

في shared code يمكنك عادةً وضع:

منطق تسجيل الدخول والتسجيل وإدارة الجلسة.
Repositories التي تتحدث مع Firestore أو خدمات HTTP أو تخزين محلي.
Use cases و validators و mappers.
Data models المشتركة التي تمثل المستخدم والرسائل والإعدادات والتفضيلات.
التعامل مع الأخطاء بطريقة موحدة، مثل تحويل أخطاء Firebase إلى أخطاء تطبيق مفهومة.
التنسيق بين خدمات متعددة، مثل “إذا نجح تسجيل الدخول اجلب ملف المستخدم ثم خزنه محليًا”.

أما platform-specific فيمكن أن يشمل:

إعداد Firebase في Android من خلال ملفات التهيئة المناسبة وتهيئة SDK الأصلي.
إعداد Firebase في iOS من خلال ملف التهيئة والربط بالمشروع Xcode.
التعامل مع الإشعارات الخاصة بالنظام.
بعض وظائف Crashlytics أو Analytics المتقدمة إذا كنت تحتاج ربطًا أدق مع النظام.
أي سلوك يعتمد على إذن من النظام أو واجهات UIKit/SwiftUI/Android UI.

هذا الفصل لا يهدف إلى التعقيد، بل إلى البساطة طويلة المدى. عندما تفصل بهذه الطريقة، يصبح من السهل أن ترى أين تبدأ مسؤوليات الـ shared code وأين تنتهي. وهذا مهم جدًا لأن المشاريع متعددة المنصات قد تتحول بسرعة إلى خليط مربك إذا حاولت أن تضع كل شيء في طبقة واحدة فقط.

أسلوبان شائعان لدمج Firebase مع Kotlin Multiplatform

في الواقع العملي، هناك أكثر من أسلوب، لكن يمكن تلخيص الأكثر شيوعًا في مسارين رئيسيين. الأول هو استخدام مكتبة Multiplatform جاهزة أو غلاف مشترك يوفر واجهة موحدة لخدمات Firebase. الثاني هو استخدام Firebase الأصلي على كل منصة مع interfaces مشتركة وexpect/actual حيث يلزم.

الأسلوب الأول مناسب عندما تريد سرعة في التنفيذ، وتوحيدًا واضحًا في الواجهة، وتقليلًا لعدد الخطوات الخاصة بكل منصة في طبقة الأعمال. وغالبًا يكون جيدًا جدًا للخدمات الشائعة مثل Authentication أو Firestore إذا كانت المكتبة المختارة ناضجة وتخدم احتياجاتك. أما الأسلوب الثاني فيناسب الفرق التي تريد تحكمًا كاملًا، أو التي لديها خبرة أعلى في المنصات الأصلية، أو التي تحتاج ميزات دقيقة جدًا لا توفرها الطبقات الجاهزة.

ليس هناك أسلوب “أفضل مطلقًا”. بل هناك أسلوب أنسب لحالتك. فريق صغير يبني MVP قد يفضل المسار الأول لأنه الأسرع. فريق لديه تطبيق كبير وحساس للأنظمة قد يفضل مسارًا هجينًا: جزء مشترك عبر مكتبات Multiplatform، وجزء platform-specific للخدمات الحساسة. وهذه المرونة هي واحدة من أجمل ما في Kotlin Multiplatform أصلًا.

تجهيز المشروع بشكل عام

في مشروع Kotlin Multiplatform النموذجي ستجد عادةً وحدة مشتركة shared ووحدتين للتطبيقات androidApp وiosApp. أحيانًا تكون البنية أكثر تعقيدًا، لكن الفكرة لا تتغير. ما يهمنا هو أن نضع خدمة Firebase في المكان الصحيح من البداية، حتى لا نضطر لاحقًا إلى إعادة ترتيب طبقات كثيرة.

من المفيد أن تبدأ بتحديد الخدمات التي تحتاجها. ليس كل مشروع يحتاج كل شيء من Firebase. قد تحتاج Authentication و Firestore فقط، بينما قد لا تحتاج Messaging في البداية. وقد تحتاج Crashlytics بعد الإطلاق الأول، وليس قبل ذلك. هذا التفريق مهم لأنه يمنعك من تحميل المشروع بتعقيدات لا قيمة لها في هذه المرحلة.

بعد ذلك، أنشئ طبقات واضحة داخل shared:

  • data للتعامل مع مصادر البيانات.

  • domain لقواعد العمل و use cases.

  • presentation إن كانت لديك طبقة مشتركة جزئيًا، أو على الأقل models و state helpers.

  • core أو common للأدوات العامة مثل error mapping و result wrappers.

ثم عرّف واجهات تعتمد عليها الواجهات والمنطق، بدل أن يلمس الكود الأعلى Firebase مباشرة. هذا سيساعدك كثيرًا عندما تريد الاختبار أو تبديل التنفيذ أو إضافة provider آخر لاحقًا. على سبيل المثال، بدل أن تقول الواجهة “سأستدعي Firebase Auth مباشرة”، قل “سأستدعي AuthRepository”. هذا الفرق البسيط يصنع مشروعًا أسهل في الفهم وأخف في التعديل.

مثال على بنية مبسطة للمشروع

يمكن أن تبدو البنية بشكل قريب من هذا التصور:

shared/
  src/commonMain/
    kotlin/
      com/example/app/
        auth/
          AuthRepository.kt
          AuthRepositoryImpl.kt
          AuthModels.kt
        data/
          FirestoreRepository.kt
          FirestoreRepositoryImpl.kt
        domain/
          LoginUseCase.kt
          ObserveSessionUseCase.kt
        core/
          AppResult.kt
          AppError.kt

وفي Android و iOS ستجد طبقات التهيئة الخاصة بـ Firebase، وربما بعض الخدمات platform-specific المرتبطة بالإشعارات أو التخزين المحلي أو الربط مع النظام.

هذا التنظيم يوفّر شيئًا مهمًا جدًا: عندما يفتح أحد أعضاء الفريق المشروع بعد شهرين أو ستة أشهر، سيفهم بسرعة أين يعيش كل شيء. وهذا ليس رفاهية، بل ضرورة في المشاريع الحقيقية.

دمج Firebase Authentication مع Kotlin Multiplatform

Authentication من أكثر الخدمات التي يبدأ بها المطورون عادةً، لأنها حجر الأساس لأي تطبيق فيه حسابات مستخدمين. والفكرة هنا بسيطة: تريد طبقة مشتركة تسمح للمستخدم بالتسجيل والدخول والخروج، وتريد في الوقت نفسه أن تكون هذه الطبقة مستقلة عن تفاصيل المنصة ما أمكن.

في shared code يمكنك تعريف نموذج مستخدم بسيط:

data class AppUser(
    val uid: String,
    val email: String?,
    val displayName: String?
)

ثم تعرّف واجهة للمصادقة:

interface AuthRepository {
    suspend fun login(email: String, password: String): Result<AppUser>
    suspend fun register(email: String, password: String): Result<AppUser>
    suspend fun logout()
    fun observeAuthState(): kotlinx.coroutines.flow.Flow<AppUser?>
}

الهدف من هذه الواجهة ليس أن “نخفي Firebase فقط”، بل أن نعبر عن احتياجات التطبيق بلغة أعمال مفهومة. التطبيق لا يهمه كيف يعمل Firebase داخليًا بقدر ما يهمه أن يحصل على مستخدم أو خطأ واضح أو حالة جلسة يمكن مراقبتها.

ثم نأتي إلى التنفيذ. إن كنت تستخدم مكتبة Multiplatform مناسبة، يمكنك ربطها هنا داخل AuthRepositoryImpl. وإن كنت تستخدم Firebase الأصلي لكل منصة، فستجعل actual implementation على كل منصة تلتف حول SDK الخاص بها. المهم أن الواجهة تبقى موحدة.

مثال توضيحي لتنفيذ مبسط يعتمد على تحويل الأخطاء:

class AuthRepositoryImpl(
    private val firebaseAuthService: FirebaseAuthService
) : AuthRepository {

    override suspend fun login(email: String, password: String): Result<AppUser> {
        return try {
            val user = firebaseAuthService.signIn(email, password)
            Result.success(
                AppUser(
                    uid = user.uid,
                    email = user.email,
                    displayName = user.displayName
                )
            )
        } catch (e: Throwable) {
            Result.failure(mapAuthException(e))
        }
    }

    override suspend fun register(email: String, password: String): Result<AppUser> {
        return try {
            val user = firebaseAuthService.createAccount(email, password)
            Result.success(
                AppUser(
                    uid = user.uid,
                    email = user.email,
                    displayName = user.displayName
                )
            )
        } catch (e: Throwable) {
            Result.failure(mapAuthException(e))
        }
    }

    override suspend fun logout() {
        firebaseAuthService.signOut()
    }

    override fun observeAuthState(): Flow<AppUser?> {
        return firebaseAuthService.authStateFlow()
    }

    private fun mapAuthException(e: Throwable): Throwable {
        return when {
            e.message?.contains("invalid", ignoreCase = true) == true ->
                IllegalArgumentException("البريد الإلكتروني أو كلمة المرور غير صحيحة")
            e.message?.contains("network", ignoreCase = true) == true ->
                IllegalStateException("تأكد من الاتصال بالإنترنت")
            else ->
                IllegalStateException("حدث خطأ غير متوقع أثناء تسجيل الدخول")
        }
    }
}

لاحظ هنا أننا لا نربط الواجهة بالمكتبة أو الـ SDK مباشرة، بل نمر عبر FirebaseAuthService. هذا مجرد مثال لإظهار الفكرة: ضع طبقة تجريد تفصل بين المنطق المشترك وبين التفاصيل التقنية.

مثال عملي على استخدام Flow لمراقبة حالة تسجيل الدخول

من الأشياء الجميلة في Kotlin هو الاندماج الطبيعي مع coroutines و Flow. وإذا كنت تبني تطبيقًا حديثًا، فغالبًا ستستفيد من هذا جدًا في تتبع حالة الجلسة.

class ObserveSessionUseCase(
    private val authRepository: AuthRepository
) {
    operator fun invoke(): Flow<AppUser?> = authRepository.observeAuthState()
}

ثم في طبقة العرض أو الـ ViewModel:

class LoginViewModel(
    private val observeSessionUseCase: ObserveSessionUseCase
) {
    val sessionFlow = observeSessionUseCase()
}

بهذه البساطة، تستطيع الواجهة في Android أو iOS أن تعرض شاشة الترحيب أو تسجيل الدخول حسب وجود المستخدم من عدمه. الجميل هنا أن السلوك نفسه يبقى مشتركًا، بينما واجهة العرض نفسها تظل حرة في كل منصة.

Firestore داخل Kotlin Multiplatform

Firestore غالبًا هو الجزء الذي يفتح الباب أمام منطق بيانات أكثر ثراءً: ملفات المستخدمين، الرسائل، الإعدادات، القوائم، المعاملات، الحالات اللحظية، وغير ذلك. وبالنسبة لتطبيق Multiplatform، Firestore ممتاز جدًا عندما يتم التعامل معه من خلال repository واضح يحول الـ documents إلى models مشتركة.

لنفترض أنك تريد حفظ ملف المستخدم. يمكنك تعريف model مشترك:

data class Profile(
    val id: String,
    val name: String,
    val avatarUrl: String?,
    val bio: String?
)

ثم interface للمصدر:

interface ProfileRepository {
    suspend fun getProfile(userId: String): Result<Profile?>
    suspend fun saveProfile(profile: Profile): Result<Unit>
    fun observeProfile(userId: String): Flow<Profile?>
}

ومنه تكتب تنفيذًا يتحدث مع Firestore أو الغلاف الذي اخترته. المثال التالي يوضح الفكرة بشكل عام:

class ProfileRepositoryImpl(
    private val firestoreService: FirestoreService
) : ProfileRepository {

    override suspend fun getProfile(userId: String): Result<Profile?> {
        return try {
            val document = firestoreService.getDocument("profiles", userId)
            val profile = document?.let {
                Profile(
                    id = it["id"] as String,
                    name = it["name"] as String,
                    avatarUrl = it["avatarUrl"] as String?,
                    bio = it["bio"] as String?
                )
            }
            Result.success(profile)
        } catch (e: Throwable) {
            Result.failure(e)
        }
    }

    override suspend fun saveProfile(profile: Profile): Result<Unit> {
        return try {
            firestoreService.setDocument(
                collection = "profiles",
                documentId = profile.id,
                data = mapOf(
                    "id" to profile.id,
                    "name" to profile.name,
                    "avatarUrl" to profile.avatarUrl,
                    "bio" to profile.bio
                )
            )
            Result.success(Unit)
        } catch (e: Throwable) {
            Result.failure(e)
        }
    }

    override fun observeProfile(userId: String): Flow<Profile?> {
        return firestoreService.observeDocument("profiles", userId).map { data ->
            data?.let {
                Profile(
                    id = it["id"] as String,
                    name = it["name"] as String,
                    avatarUrl = it["avatarUrl"] as String?,
                    bio = it["bio"] as String?
                )
            }
        }
    }
}

مرة أخرى، لا تركز فقط على اسم الخدمة. الأهم هو أن تملك طبقة mapping واضحة. لأن أكبر مشكلة في دمج قواعد بيانات سحابية داخل تطبيقات Multiplatform ليست “هل أستطيع الوصول إلى البيانات؟” بل “هل ستحافظ على تنظيم data model و error handling بعد أشهر من التطوير؟”.

كيف تتعامل مع Firestore بطريقة صحية؟

الكثيرون يبدؤون باستخدام Firestore بطريقة مباشرة جدًا، ثم يجدون أنفسهم في مشروع يصعب اختباره أو تغيير بنيته. الحل ليس في الابتعاد عن Firestore، بل في التعامل معه كمصدر بيانات ضمن repository، وليس كجزء من الواجهة أو الـ ViewModel. هذا فرق مهم جدًا.

هناك قواعد بسيطة مفيدة هنا:

لا تضع استدعاءات Firestore المباشرة في طبقة العرض.
لا ترسل documents الخام إلى الواجهة.
لا تجعل أسماء الحقول مكررة في أكثر من مكان بدون سبب.
استخدم mappers واضحة بين DTO و domain model.
وحّد الأخطاء حتى لا تتعامل الواجهة مع exceptions مبهمة.

على سبيل المثال، بدل أن تعرض الواجهة Map<String, Any>، حوّل البيانات إلى Profile أو Message أو Conversation. الواجهة لا ينبغي أن تعرف شكل الوثيقة الداخلية في قاعدة البيانات السحابية. هذا من أساسيات البناء النظيف، سواء كنت تستخدم Firebase أو أي مصدر آخر.

التعامل مع الأخطاء بطريقة إنسانية ومفهومة

هذه نقطة غالبًا يتم تجاهلها في المقالات التقنية رغم أنها من أكثر النقاط التي تؤثر في تجربة المستخدم. عندما يفشل تسجيل الدخول أو يفشل جلب البيانات، المستخدم لا يريد رسالة تقنية مثل “Exception: permission denied at line 42”. هو يريد رسالة مفهومة يمكنه التصرف بناءً عليها. وفي Kotlin Multiplatform، لأنك تجمع أكثر من منصة، من الأفضل جدًا أن يكون لديك نظام أخطاء موحد.

يمكنك بناء كلاس بسيط للأخطاء:

sealed class AppError {
    data object Network : AppError()
    data object Unauthorized : AppError()
    data object NotFound : AppError()
    data class Unknown(val message: String? = null) : AppError()
}

ثم نتيجة عامة:

sealed class AppResult<out T> {
    data class Success<T>(val data: T) : AppResult<T>()
    data class Error(val error: AppError) : AppResult<Nothing>()
}

وبعدها تكتب mapper:

fun Throwable.toAppError(): AppError {
    val message = this.message.orEmpty()

    return when {
        message.contains("permission", ignoreCase = true) -> AppError.Unauthorized
        message.contains("network", ignoreCase = true) -> AppError.Network
        message.contains("not found", ignoreCase = true) -> AppError.NotFound
        else -> AppError.Unknown(message)
    }
}

هذا النمط يساعدك على توحيد التجربة بين Android و iOS. بدل أن يكتب كل تطبيق منطق أخطاء خاصًا به، يصبح لديك قاموس واحد يفهمه الجميع. والأجمل من ذلك أنه يجعل الترجمة والتخصيص أسهل بكثير لاحقًا.

مثال على Use Case كامل

class LoginUseCase(
    private val authRepository: AuthRepository
) {
    suspend operator fun invoke(email: String, password: String): AppResult<AppUser> {
        return authRepository.login(email, password)
            .fold(
                onSuccess = { AppResult.Success(it) },
                onFailure = { AppResult.Error(it.toAppError()) }
            )
    }
}

في طبقة العرض يمكنك التعامل مع AppResult من دون معرفة شيء عن Firebase نفسه. وهذا بالضبط ما يريده أي مشروع Multiplatform ناضج: منطق أعمال واضح، وتفاصيل تقنية معزولة.

إعداد Firebase في Android

على Android، عادةً تبدأ من ملف التهيئة الخاص بالمشروع، ثم تضيف SDK المناسب والخدمات التي تحتاجها. أما في Kotlin Multiplatform، فمن المهم أن تضع هذا الإعداد في المكان الصحيح، بحيث لا يتسرب إلى الشفرة المشتركة إلا ما يجب أن يكون مشتركًا فعلًا.

غالبًا ستحتاج إلى:

  1. ربط المشروع مع Firebase console.

  2. تنزيل ملف الإعداد الخاص بـ Android.

  3. إضافة الاعتمادات المناسبة.

  4. تهيئة الخدمة عند بدء التطبيق.

مثال مبسط:

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        FirebaseInitializer.initialize(this)
    }
}

والـ initializer قد يكون خاصًا بالمنصة:

object FirebaseInitializer {
    fun initialize(context: Context) {
        // FirebaseApp.initializeApp(context)
        // تهيئة الخدمات التي تحتاجها
    }
}

الخطأ الشائع هنا هو أن يحاول المطور أن يضع كل شيء في shared مباشرة دون أي اعتبار لإعدادات المنصة. هذا قد يعمل جزئيًا، لكنه غالبًا يخلق صعوبات لاحقًا عندما تريد إضافة Messaging أو Crashlytics أو حتى تبديل بيئة التطوير والإنتاج.

إعداد Firebase في iOS

على iOS، الفكرة نفسها تقريبًا لكن الأدوات مختلفة. ستتعامل مع ملف الإعداد داخل المشروع، وتنفذ التهيئة في مكان مناسب داخل App lifecycle. في Swift أو SwiftUI، يمكنك أن تجعل Firebase يبدأ عند تشغيل التطبيق.

مثال تصوري:

import SwiftUI
import FirebaseCore

@main
struct iOSApp: App {
    init() {
        FirebaseApp.configure()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

إذا كانت طبقة shared تتحدث مع Firebase من خلال غلاف Multiplatform، فأنت غالبًا لن تحتاج أن تكرر الكثير من المنطق في iOS نفسه. المهم هنا هو أن تكون التهيئة صحيحة وأن تلتزم بنمط الربط الذي اختارته في المشروع.

دمج Firebase مع coroutines و Flow بشكل عملي

من أبرز نقاط القوة في Kotlin Multiplatform أنك تستطيع كتابة code أكثر نظافة عندما تربط Firebase مع coroutines و Flow. وهذا مهم جدًا خصوصًا للخدمات التي تدعم التدفق المستمر للبيانات، مثل جلسة المستخدم أو document listeners أو المستندات التي تتغير لحظة بلحظة.

مثال على repository يعيد Flow:

class MessagesRepository(
    private val firestoreService: FirestoreService
) {
    fun observeConversation(conversationId: String): Flow<List<Message>> {
        return firestoreService.observeCollection("conversations/$conversationId/messages")
            .map { items ->
                items.mapNotNull { data ->
                    val id = data["id"] as? String ?: return@mapNotNull null
                    val text = data["text"] as? String ?: ""
                    val senderId = data["senderId"] as? String ?: ""
                    val createdAt = data["createdAt"] as? Long ?: 0L

                    Message(
                        id = id,
                        text = text,
                        senderId = senderId,
                        createdAt = createdAt
                    )
                }
            }
    }
}

ومن ثم في الواجهة أو الـ ViewModel:

class ConversationViewModel(
    private val messagesRepository: MessagesRepository
) {
    fun observeMessages(conversationId: String): Flow<List<Message>> {
        return messagesRepository.observeConversation(conversationId)
    }
}

هذا النمط ممتاز لأنه يحوّل Firebase من مصدر بيانات “مباشر” إلى مصدر بيانات “منظم”. والفرق بين الاثنين ضخم جدًا عندما يكبر التطبيق.

هل نستخدم dependency injection؟

الإجابة المختصرة: نعم، وبشدة.
السبب بسيط جدًا: لأن مشروعًا متعدد المنصات سريعًا ما يصبح كبيرًا، ومع وجود Firebase وخدماته المختلفة ستحتاج إلى طريقة منظمة لحقن الاعتماديات. سواء استخدمت Koin أو أي أداة أخرى، الفكرة هي أن تحتفظ بمرونة تكوين المشروع خارج الكود نفسه.

مثال تصوري:

val sharedModule = module {
    single<AuthRepository> { AuthRepositoryImpl(get()) }
    single<ProfileRepository> { ProfileRepositoryImpl(get()) }
    single<LoginUseCase> { LoginUseCase(get()) }
}

هذا يسهل أيضًا استبدال التنفيذ أثناء الاختبار:

class FakeAuthRepository : AuthRepository {
    override suspend fun login(email: String, password: String): Result<AppUser> {
        return Result.success(AppUser("123", email, "Test User"))
    }

    override suspend fun register(email: String, password: String): Result<AppUser> {
        return Result.success(AppUser("123", email, "Test User"))
    }

    override suspend fun logout() {}
    override fun observeAuthState(): Flow<AppUser?> = flowOf(null)
}

ومع هذا تستطيع اختبار use cases والـ ViewModels بدون Firebase حقيقي. وهذا كنز حقيقي أثناء التطوير المستمر.

ما الفائدة من فصل DTO عن Domain Model؟

هذه نقطة تقنية صغيرة في ظاهرها، لكنها من أكثر الأشياء التي تحمي المشروع على المدى البعيد. عندما تسحب البيانات من Firestore أو Firebase بشكل مباشر، ستحصل غالبًا على شكل بيانات أقرب إلى الـ DTO. أما domain model فهو الشكل الذي يخص التطبيق نفسه، وليس قاعدة البيانات. ولهذا ينبغي أن يكون هناك mapping واضح بينهما.

مثال:

data class UserDto(
    val id: String? = null,
    val email: String? = null,
    val name: String? = null
)

data class UserProfile(
    val id: String,
    val email: String,
    val name: String
)

ثم mapper:

fun UserDto.toDomain(): UserProfile? {
    val safeId = id ?: return null
    val safeEmail = email ?: return null
    val safeName = name ?: "مستخدم"

    return UserProfile(
        id = safeId,
        email = safeEmail,
        name = safeName
    )
}

بذلك، لو تغيّر شكل الوثيقة في Firebase أو أضفت حقلاً جديدًا أو حذفت آخر، يبقى التطبيق نفسه محميًا طالما أن mapper يعرف كيف يتعامل مع هذا التغير. وهذا أسلوب بالغ الأهمية عندما تتوسع الفرق أو يصبح هناك أكثر من مصدر بيانات.

ماذا عن Crashlytics و Analytics و Messaging؟

هذه الخدمات مفيدة جدًا، لكنها تحتاج أحيانًا إلى تفكير إضافي داخل Kotlin Multiplatform. ليس لأن استخدامها صعب بالضرورة، بل لأن طبيعتها ترتبط كثيرًا بالمنصة والنظام والإعدادات الخاصة بكل تطبيق.

Crashlytics مثلًا ممتاز لتتبع الأعطال في الإنتاج، لكنه غالبًا يكون جزءًا من إعدادات Android و iOS في طبقات المنصة. ومع ذلك يمكن للمنطق المشترك أن يرسل أحداثًا أو رسائل خطأ موحدة إلى طبقة platform-specific تتولى تسجيلها. بهذه الطريقة لا يفترض shared code أنه يعرف كيفية التواصل مع Crashlytics مباشرة، بل يكتفي بإرسال signal واضح.

Analytics كذلك يمكن أن يظل قريبًا من الواجهة أو طبقة الأحداث، بينما يتولى كل تطبيق على منصته إرسال الأحداث بالشكل المناسب. وأما Messaging أو Push Notifications، فهي من الأشياء التي تستفيد غالبًا من تصميم متوازن: منطق الإشعارات العام في shared، والتفاصيل الخاصة بالإذن والتسجيل والـ token في الطبقة الخاصة بكل منصة.

القاعدة العامة هنا هي ألا تضع جميع الخدمات تحت نفس السقف فقط لأنها كلها اسمها Firebase. كل خدمة لها طبيعتها. وبعضها ممتاز للمشاركة الكاملة، وبعضها يحتاج طبقة توزيع أذكى.

مثال واقعي: تطبيق دردشة متعدد المنصات

لنأخذ سيناريو بسيطًا: تطبيق دردشة يعمل على Android و iOS، ويحتاج تسجيل دخول، ملف مستخدم، قائمة محادثات، ورسائل لحظية. في هذا السيناريو، Firebase مناسب جدًا، وKotlin Multiplatform أيضًا مناسب جدًا.

يمكن أن تكون الخطة كالتالي:

  • Authentication في shared عبر repository موحد.

  • المستخدم وملفاته داخل shared models.

  • Firestore للمحادثات والرسائل عبر repository مشترك.

  • Push notifications في كل منصة بشكل مناسب.

  • UI خاص بكل منصة لكن يستهلك نفس الـ ViewModels أو state holders المشتركة.

مثال على model الرسالة:

data class Message(
    val id: String,
    val text: String,
    val senderId: String,
    val createdAt: Long
)

ثم repository:

interface ChatRepository {
    fun observeMessages(chatId: String): Flow<List<Message>>
    suspend fun sendMessage(chatId: String, message: Message): Result<Unit>
}

والتنفيذ يخزن الرسائل في Firestore.
في الواجهة، سواء كانت Compose Multiplatform أو SwiftUI أو UIKit، أنت تتعامل فقط مع Flow<List<Message>> أو state مشابه. وهذا يجعل التطبيق أنظف بكثير من أي ربط مباشر بالخدمة السحابية.

الأداء والموثوقية

من الطبيعي أن يسأل أي مطور: هل هذا النهج سريع ومستقر؟ والإجابة في الغالب نعم، إذا كان التصميم جيدًا. Kotlin Multiplatform لا يعني تلقائيًا أداءً أضعف، وFirebase بحد ذاته أيضًا ليس عائقًا. المشكلة عادة تظهر عندما يتم ربط كل شيء بشكل مباشر من الواجهة أو عندما تتضخم طبقات التحويل بلا حاجة.

لذلك، راقب هذه النقاط:

  • قلل عدد التحويلات غير الضرورية.

  • لا تجلب من Firebase بيانات أكثر من اللازم.

  • استخدم caching عندما يكون مناسبًا.

  • اجعل الـ flows والlisteners نظيفة ومغلقة عند انتهاء الحاجة.

  • انتبه إلى lifecycle لكل منصة حتى لا تترك subscriptions مفتوحة.

في التطبيقات الحقيقية، كثير من مشاكل الأداء لا تأتي من Firebase نفسه، بل من كثرة الاستدعاءات، أو من بنية repo سيئة، أو من نمط UI يعيد الاشتراك في البيانات أكثر من اللازم. وهذا خبر جيد، لأنه يعني أنك تملك السيطرة على معظم المشاكل من خلال التصميم الجيد.

الاختبار: أين تكمن القوة الحقيقية؟

من أجمل فوائد Kotlin Multiplatform أن اختبار المنطق المشترك يصبح أسهل من اختبار منطق موزع على أكثر من تطبيق. وعندما تبني Firebase integration بشكل repository/interface، يصبح بإمكانك كتابة unit tests حقيقية بدون الحاجة إلى تشغيل Firebase فعليًا في كل مرة.

مثال اختبار بسيط:

class LoginUseCaseTest {

    private val fakeAuthRepository = FakeAuthRepository()
    private val loginUseCase = LoginUseCase(fakeAuthRepository)

    @Test
    fun `login returns success when credentials are valid`() = runTest {
        val result = loginUseCase("test@example.com", "123456")

        assertTrue(result is AppResult.Success)
    }
}

هذا الاختبار لا يعرف شيئًا عن Firebase. وهو لا يحتاج أن يعرف.
وهنا تظهر الحكمة الحقيقية من الفصل بين الطبقات: عندما ينجح الاختبار، فأنت تختبر سلوك تطبيقك، لا مكتبة الطرف الثالث.

التحديات الشائعة وكيف تتجاوزها

حتى تكون الصورة واقعية، من الجيد أن نتحدث عن التحديات بوضوح. أول تحدٍّ هو اختلاف نضج الدعم بين الخدمات المختلفة. قد تجد أن خدمة معينة سهلة جدًا مع shared code، بينما خدمة أخرى تحتاج workarounds أو تكاملًا platform-specific. هذا طبيعي، ولا يعني أن المشروع فشل، بل يعني فقط أنك بحاجة إلى قرار معماري واعٍ.

التحدي الثاني هو التهيئة والإعدادات. Firebase يعتمد كثيرًا على ملفات التهيئة الصحيحة، ومع Multiplatform قد يزداد احتمال نسيان جزء من الإعداد في إحدى المنصات. الحل هنا هو checklist واضحة لكل منصة، وربما script أو وثيقة داخل المشروع تشرح الخطوات الأساسية.

التحدي الثالث هو اختلاف التفكير بين الفرق. مطور Android قد يرغب في حل قريب من Android SDK، ومطور iOS قد يفضل نمطًا آخر، بينما فريق الـ shared code يريد abstraction أنظف. الحل ليس في فرض رأي واحد، بل في اختيار مستوى abstraction يخدم الفريق كله ولا يبالغ في التعقيد.

التحدي الرابع هو الرغبة في “مشاركة كل شيء”. وهذا فخ مشهور. ليست كل وظيفة يجب أن تكون مشتركة. أحيانًا يكون أفضل قرار هو أن تترك جزءًا معينًا platform-specific حتى لو بدا ذلك أقل “أناقة” على الورق. في الواقع العملي، القابلية للصيانة أهم من الجمال النظري.

متى يكون Firebase مع KMP خيارًا ممتازًا؟

يكون هذا الخيار ممتازًا عندما:

  • تريد إطلاقًا أسرع مع بنية نظيفة.

  • لديك تطبيق أو أكثر يشتركون في منطق أساسي.

  • تريد تقليل التكرار بين Android و iOS.

  • تعتمد على خدمات سحابية شائعة مثل Authentication و Firestore.

  • لديك فريق يريد اختبارًا أسهل ومنطقًا مركزيًا.

  • تريد أن تبني MVP ثم تتوسع بدون إعادة كتابة كل شيء.

أما إذا كان تطبيقك يعتمد بشكل كبير جدًا على تفاعلات عميقة خاصة بكل منصة، أو على SDKs لا تتوافق بسهولة مع بنية Multiplatform، فقد يكون من الأفضل أن تبدأ بحل هجين. وهنا أيضًا لا يوجد خطأ. المعمارية الجيدة هي التي تناسب المنتج، لا التي تبدو أكثر “نظافة” على الورق فقط.

نصائح عملية من واقع الخبرة

من الأفضل دائمًا أن تبدأ صغيرًا.
لا تحاول دمج كل خدمات Firebase دفعة واحدة في مشروع KMP جديد.
ابدأ بـ Authentication أو Firestore فقط، ثم وسّع تدريجيًا.
ضع layer واضحة للـ repositories.
استخدم models مشتركة بسيطة في البداية.
وحّد الأخطاء من أول يوم.
واكتب اختبارًا واحدًا على الأقل لكل use case مهم.

إذا كانت لديك واجهة Compose على Android و SwiftUI على iOS، فلا بأس أن يكون لكل واحدة طريقة عرضها الخاصة، لكن اجعل الحالة والمنطق والبيانات الحية مشتركة قدر الإمكان. هذا يوفر وقتًا هائلًا ويجعل التطوير أكثر اتزانًا.

ومن النصائح المهمة أيضًا ألا تربط نجاح المشروع بوجود “حل سحري” واحد. في الحقيقة، النجاح يأتي من الجمع بين قرارات صغيرة صحيحة: repository جيد، mapping واضح، errors مفهومة، DI منظم، وFirebase موضوع في المكان المناسب. هذه التفاصيل الصغيرة هي التي تصنع الفرق بين مشروع يُستمتع به ومشروع يتعبك بعد أشهر.

مثال نهائي مبسط لبنية متكاملة

class SharedAuthController(
    private val loginUseCase: LoginUseCase,
    private val observeSessionUseCase: ObserveSessionUseCase
) {

    suspend fun login(email: String, password: String): AppResult<AppUser> {
        return loginUseCase(email, password)
    }

    fun session(): Flow<AppUser?> {
        return observeSessionUseCase()
    }
}

هذا النوع من الطبقات يجعل دمج Firebase يبدو طبيعيًا داخل Kotlin Multiplatform بدل أن يبدو كحل ملتصق بالمشروع.
أنت هنا لا “تحشو” Firebase داخل التطبيق، بل تضعه كخدمة خلفية لخدمة منطق التطبيق.

خاتمة

دمج Firebase مع تطبيقات Kotlin Multiplatform ليس مجرد اتجاه تقني جميل، بل هو خيار عملي جدًا عندما تريد أن تبني تطبيقات متعددة المنصات بذكاء، وبوقت أقل، وبصيانة أفضل. السر الحقيقي ليس في Firebase نفسه ولا في Kotlin Multiplatform وحده، بل في الطريقة التي تجمع بهما بين الوضوح والمرونة. عندما تصمم shared code بشكل جيد، وتفصل بين domain و data، وتترك تفاصيل المنصة حيث يجب أن تبقى، ستكتشف أن هذا الثنائي قادر على تقليل التكرار ورفع الإنتاجية من دون أن يفرض عليك تنازلات مؤلمة.

الأمر يحتاج قليلًا من الانضباط في البداية، لكنه يرد لك هذا الجهد بسرعة. ستكتب كودًا أقل تكرارًا، ستفهم التطبيق بشكل أفضل، وستتمكن من التوسع بثقة أكبر عندما يكبر المنتج أو يتغير. وربما الأهم من كل ذلك أنك ستشعر أن المشروع منظم بالفعل، لا مجرد مجموعة ملفات تعمل بالصدفة. وهذا هو الفارق الحقيقي بين دمج تقني “يشتغل” ودمج تقني “يعيش”.

#Kotlin Multiplatform #Firebase #دمج Firebase مع KMP #Firebase KMP #Kotlin Multiplatform Mobile #KMM #Android #iOS #Firestore #Authentication #Firebase SDK #Kotlin coroutines #shared code #multiplatform architecture

اشترك في نشرتنا البريدية

12k+

المشتركون

أسبوعيًا

التكرار

مجاني

دائمًا