اكتشف مزايا هندسة Android النظيفة

نشرت: 2022-03-11

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

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

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

مثال التطبيق

سننشئ تطبيق Android بسيطًا لتسجيل موقعنا بالميزات التالية:

  • يمكن للمستخدم إنشاء حساب باسم.
  • يمكن للمستخدم تحرير اسم الحساب.
  • يمكن للمستخدم حذف الحساب.
  • يمكن للمستخدم تحديد الحساب النشط.
  • يمكن للمستخدم حفظ الموقع.
  • يمكن للمستخدم رؤية قائمة المواقع للمستخدم.
  • يمكن للمستخدم رؤية قائمة المستخدمين.

هندسة معمارية نظيفة

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

مسؤوليات الطبقة:

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

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

والعكس - هل يجب أن تعرف طبقة النموذج عن طبقة العرض؟ مرة أخرى - لا ، لأننا إذا غيرنا ، على سبيل المثال ، مصدر البيانات من قاعدة بيانات إلى شبكة ، فلا يجب تغيير أي شيء في واجهة المستخدم (إذا فكرت في إضافة أداة تحميل هنا - نعم ، ولكن يمكننا أيضًا الحصول على أداة تحميل لواجهة المستخدم عند استخدام قاعدة بيانات). لذلك الطبقتان منفصلتان تمامًا. رائعة!

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

كفى نظرية الآن. لنبدأ البرمجة! تدور هذه المقالة حول الكود ، لذا - من أجل فهم أفضل - يجب عليك تنزيل الكود من GitHub والتحقق مما بداخله. هناك ثلاث علامات Git تم إنشاؤها - architecture_v1 و architecture_v2 و architecture_v3 ، والتي تتوافق مع أجزاء المقالة.

تكنولوجيا التطبيقات

في التطبيق ، أستخدم Kotlin و Dagger 2 لحقن التبعية. لا تعد Kotlin أو Dagger 2 ضرورية هنا ، لكنها تجعل الأمور أسهل بكثير. قد تتفاجأ لأنني لا أستخدم RxJava (ولا RxKotlin) ، لكنني لم أجدها قابلة للاستخدام هنا ، ولا أحب استخدام أي مكتبة فقط لأنها موجودة في المقدمة ويقول أحدهم إنها ضرورية. كما قلت - اللغة والمكتبات تفاصيل ، لذا يمكنك استخدام ما تريد. يتم أيضًا استخدام بعض مكتبات اختبار وحدات Android: JUnit و Robolectric و Mockito.

اختصاص

الطبقة الأكثر أهمية في تصميم بنية تطبيقات Android لدينا هي طبقة المجال. لنبدأ به. هذا هو المكان الذي سيكون فيه منطق عملنا وواجهات التواصل مع الطبقات الأخرى. الجوهر الرئيسي هو UseCase s ، والذي يعكس ما يمكن للمستخدم فعله بتطبيقنا. لنعد لهم فكرة مجردة:

 abstract class UseCase<out Type, in Params> { private var job: Deferred<OneOf<Failure, Type>>? = null abstract suspend fun run(params: Params): OneOf<Failure, Type> fun execute(params: Params, onResult: (OneOf<Failure, Type>) -> Unit) { job?.cancel() job = async(CommonPool) { run(params) } launch(UI) { val result = job!!.await() onResult(result) } } open fun cancel() { job?.cancel() } open class NoParams }

قررت استخدام coroutines Kotlin هنا. يجب على كل UseCase تنفيذ طريقة تشغيل لتوفير البيانات. يتم استدعاء هذه الطريقة في سلسلة رسائل في الخلفية ، وبعد تلقي النتيجة ، يتم تسليمها في مؤشر ترابط واجهة المستخدم. النوع الذي تم إرجاعه هو OneOf<F, T> —يمكننا إرجاع خطأ أو نجاح مع البيانات:

 sealed class OneOf<out E, out S> { data class Error<out E>(val error: E) : OneOf<E, Nothing>() data class Success<out S>(val data: S) : OneOf<Nothing, S>() val isSuccess get() = this is Success<S> val isError get() = this is Error<E> fun <E> error(error: E) = Error(error) fun <S> success(data: S) = Success(data) fun oneOf(onError: (E) -> Any, onSuccess: (S) -> Any): Any = when (this) { is Error -> onError(error) is Success -> onSuccess(data) } }

تحتاج طبقة المجال إلى كياناتها الخاصة ، لذا فإن الخطوة التالية هي تحديدها. لدينا كيانان في الوقت الحالي: User و UserLocation :

 data class User(var id: Int? = null, val name: String, var isActive: Boolean = false) data class UserLocation(var id: Int? = null, val latitude: Double, val longitude: Double, val time: Long, val userId: Int)

الآن بعد أن عرفنا البيانات المراد إرجاعها ، يتعين علينا الإعلان عن واجهات موفري البيانات لدينا. ستكون هذه IUsersRepository و ILocationsRepository . يجب تنفيذها في طبقة النموذج:

 interface IUsersRepository { fun setActiveUser(userId: Int): OneOf<Failure, User> fun getActiveUser(): OneOf<Failure, User?> fun createUser(user: User): OneOf<Failure, User> fun removeUser(userId: Int): OneOf<Failure, User?> fun editUser(user: User): OneOf<Failure, User> fun users(): OneOf<Failure, List<User>> } interface ILocationsRepository { fun locations(userId: Int): OneOf<Failure, List<UserLocation>> fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> }

يجب أن تكون مجموعة الإجراءات هذه كافية لتوفير البيانات اللازمة للتطبيق. في هذه المرحلة ، لا نقرر كيف سيتم تخزين البيانات - هذه تفاصيل نريد أن نكون مستقلين عنها. في الوقت الحالي ، لا تعرف طبقة المجال الخاصة بنا أنها على Android. سنحاول الحفاظ على هذه الحالة (نوع من. سأشرح لاحقًا).

الخطوة الأخيرة (أو الأخيرة تقريبًا) هي تحديد تطبيقات UseCase الخاصة بنا ، والتي سيتم استخدامها بواسطة بيانات العرض التقديمي. جميعها بسيطة جدًا (تمامًا مثل تطبيقنا وبياناتنا بسيطة) - تقتصر عملياتها على استدعاء طريقة مناسبة من المستودع ، على سبيل المثال:

 class GetLocations @Inject constructor(private val repository: ILocationsRepository) : UseCase<List<UserLocation>, UserIdParams>() { override suspend fun run(params: UserIdParams): OneOf<Failure, List<UserLocation>> = repository.locations(params.userId) }

إن تجريد Repository يجعل UseCases بنا أمرًا سهلاً للغاية - ولا يتعين علينا الاهتمام بشبكة أو قاعدة بيانات. يمكن السخرية منه بأي شكل من الأشكال ، لذلك ستختبر اختبارات الوحدة لدينا حالات الاستخدام الفعلي وليس الفئات الأخرى غير ذات الصلة. هذا سيجعل اختبارات الوحدة الخاصة بنا بسيطة وسريعة:

 @RunWith(MockitoJUnitRunner::class) class GetLocationsTests { private lateinit var getLocations: GetLocations private val locations = listOf(UserLocation(1, 1.0, 1.0, 1L, 1)) @Mock private lateinit var locationsRepository: ILocationsRepository @Before fun setUp() { getLocations = GetLocations(locationsRepository) } @Test fun `should call getLocations locations`() { runBlocking { getLocations.run(UserIdParams(1)) } verify(locationsRepository, times(1)).locations(1) } @Test fun `should return locations obtained from locationsRepository`() { given { locationsRepository.locations(1) }.willReturn(OneOf.Success(locations)) val returnedLocations = runBlocking { getLocations.run(UserIdParams(1)) } returnedLocations shouldEqual OneOf.Success(locations) } }

في الوقت الحالي ، تم الانتهاء من طبقة المجال.

نموذج

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

 class MemoryLocationsRepository @Inject constructor(): ILocationsRepository { private val locations = mutableListOf<UserLocation>() override fun locations(userId: Int): OneOf<Failure, List<UserLocation>> = OneOf.Success(locations.filter { it.userId == userId }) override fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> { val addedLocation = location.copy(id = locations.size + 1) locations.add(addedLocation) return OneOf.Success(addedLocation) } }

عرض تقديمي

قبل عامين ، كتبت مقالًا عن MVP كهيكل تطبيق جيد جدًا لنظام Android. عندما أعلنت Google عن المكونات المعمارية الرائعة ، والتي جعلت تطوير تطبيقات Android أسهل بكثير ، لم تعد هناك حاجة إلى MVP ويمكن استبدالها بـ MVVM ؛ ومع ذلك ، فإن بعض الأفكار من هذا النمط لا تزال مفيدة للغاية - مثل تلك المتعلقة بآراء غبية. يجب أن يهتموا فقط بعرض البيانات. لتحقيق ذلك ، سنستخدم ViewModel و LiveData.

تصميم تطبيقنا بسيط للغاية - نشاط واحد مع التنقل السفلي ، حيث يُظهر إدخالان في القائمة جزء locations أو جزء users . في هذه العروض ، نستخدم ViewModels ، والتي بدورها تستخدم UseCase s من طبقة المجال ، مما يحافظ على الاتصال أنيقًا وبسيطًا. على سبيل المثال ، إليك LocationsViewModel :

 class LocationsViewModel @Inject constructor(private val getLocations: GetLocations, private val saveLocation: SaveLocation) : BaseViewModel() { var locations = MutableLiveData<List<UserLocation>>() fun loadLocations(userId: Int) { getLocations.execute(UserIdParams(userId)) { it.oneOf(::handleError, ::handleLocationsChange) } } fun saveLocation(location: UserLocation, onSaved: (UserLocation) -> Unit) { saveLocation.execute(UserLocationParams(location)) { it.oneOf(::handleError) { location -> handleLocationSave(location, onSaved) } } } private fun handleLocationSave(location: UserLocation, onSaved: (UserLocation) -> Unit) { val currentLocations = locations.value?.toMutableList() ?: mutableListOf() currentLocations.add(location) this.locations.value = currentLocations onSaved(location) } private fun handleLocationsChange(locations: List<UserLocation>) { this.locations.value = locations } }

شرح بسيط لأولئك الذين ليسوا على دراية بـ ViewModels - يتم تخزين بياناتنا في متغير المواقع. عندما نحصل على بيانات من حالة استخدام getLocations ، يتم تمريرها إلى قيمة LiveData . سيؤدي هذا التغيير إلى إخطار المراقبين حتى يتمكنوا من الرد وتحديث بياناتهم. نضيف مراقبًا للبيانات الموجودة في جزء:

 class LocationsFragment : BaseFragment() { ... private fun initLocationsViewModel() { locationsViewModel = ViewModelProviders.of(activity!!, viewModelFactory)[LocationsViewModel::class.java] locationsViewModel.locations.observe(this, Observer<List<UserLocation>> { showLocations(it ?: emptyList()) }) locationsViewModel.error.observe(this, Observer<Failure> { handleError(it) }) } private fun showLocations(locations: List<UserLocation>) { locationsAdapter.locations = locations } private fun handleError(error: Failure?) { toast(R.string.user_fetch_error).show() } }

في كل تغيير موقع ، نقوم فقط بتمرير البيانات الجديدة إلى محول مخصص لعرض إعادة التدوير - وهذا هو المكان الذي ينتقل إليه تدفق Android العادي لعرض البيانات في عرض إعادة التدوير.

نظرًا لأننا نستخدم ViewModel في وجهات نظرنا ، فمن السهل أيضًا اختبار سلوكهم - يمكننا فقط أن نسخر من ViewModels ولا نهتم بمصدر البيانات أو الشبكة أو عوامل أخرى:

 @RunWith(RobolectricTestRunner::class) @Config(application = TestRegistryRobolectricApplication::class) class LocationsFragmentTests { private var usersViewModel = mock(UsersViewModel::class.java) private var locationsViewModel = mock(LocationsViewModel::class.java) lateinit var fragment: LocationsFragment @Before fun setUp() { UsersViewModelMock.intializeMock(usersViewModel) LocationsViewModelMock.intializeMock(locationsViewModel) fragment = LocationsFragment() fragment.viewModelFactory = ViewModelUtils.createFactoryForViewModels(usersViewModel, locationsViewModel) startFragment(fragment) } @Test fun `should getActiveUser on start`() { Mockito.verify(usersViewModel).getActiveUser() } @Test fun `should load locations from active user`() { usersViewModel.activeUserId.value = 1 Mockito.verify(locationsViewModel).loadLocations(1) } @Test fun `should display locations`() { val date = Date(1362919080000)//10-03-2013 13:38 locationsViewModel.locations.value = listOf(UserLocation(1, 1.0, 2.0, date.time, 1)) val recyclerView = fragment.find<RecyclerView>(R.id.locationsRecyclerView) recyclerView.measure(100, 100) recyclerView.layout(0,0, 100, 100) val adapter = recyclerView.adapter as LocationsListAdapter adapter.itemCount `should be` 1 val viewHolder = recyclerView.findViewHolderForAdapterPosition(0) as LocationsListAdapter.LocationViewHolder viewHolder.latitude.text `should equal` "Lat: 1.0" viewHolder.longitude.text `should equal` "Lng: 2.0" viewHolder.locationDate.text `should equal` "10-03-2013 13:38" } }

قد تلاحظ أن طبقة العرض التقديمي مفصولة أيضًا إلى طبقات أصغر ذات حدود واضحة. تعد طرق العرض مثل activities ViewHolders fragments إلى ذلك مسؤولة فقط عن عرض البيانات. إنهم على دراية فقط بطبقة ViewModel — ويستخدمونها فقط للحصول على المستخدمين والمواقع أو لإرسالهم. إنه ViewModel الذي يتصل بالمجال. تطبيقات ViewModel هي نفسها للعرض مثل UseCases للمجال. لإعادة الصياغة ، تشبه العمارة النظيفة البصل - فهي تحتوي على طبقات ، ويمكن أن تحتوي الطبقات أيضًا على طبقات.

حقن التبعية

لقد أنشأنا جميع الفئات لمعمارتنا ، ولكن هناك شيء آخر يجب القيام به - نحتاج إلى شيء يربط كل شيء معًا. يتم الاحتفاظ بطبقات العرض والمجال والنموذج نظيفة ، لكننا نحتاج إلى وحدة واحدة ستكون متسخة وستعرف كل شيء عن كل شيء - من خلال هذه المعرفة ، ستكون قادرة على ربط طبقاتنا. أفضل طريقة لعمله هي استخدام أحد أنماط التصميم الشائعة (أحد مبادئ الكود النظيف المحددة في SOLID) - حقن الاعتماد ، الذي يخلق كائنات مناسبة لنا ويحقنها في التبعيات المرغوبة. لقد استخدمت Dagger 2 هنا (في منتصف المشروع ، قمت بتغيير الإصدار إلى 2.16 ، والذي يحتوي على عدد أقل من النماذج المعيارية) ، ولكن يمكنك استخدام أي آلية تريدها. في الآونة الأخيرة ، لعبت قليلاً مع مكتبة Koin ، وأعتقد أنها تستحق المحاولة أيضًا. كنت أرغب في استخدامه هنا ، ولكن كان لدي الكثير من المشاكل مع السخرية من ViewModels عند الاختبار. آمل أن أجد طريقة لحلها بسرعة - إذا كان الأمر كذلك ، يمكنني تقديم الاختلافات لهذا التطبيق عند استخدام Koin and Dagger 2.

يمكنك التحقق من التطبيق لهذه المرحلة على GitHub باستخدام العلامة architecture_v1.

التغييرات

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

التغيير 1

أول شيء عند استخدام الغرفة هو تحديد الكيانات لقاعدة البيانات. لدينا بالفعل بعض: User و UserLocation . كل ما يتعين علينا القيام به هو إضافة تعليقات توضيحية مثل @Entity و @PrimaryKey ، ومن ثم يمكننا استخدامها في طبقة النموذج الخاصة بنا مع قاعدة بيانات. رائعة! هذه طريقة ممتازة لكسر جميع قواعد الهندسة المعمارية التي أردنا الاحتفاظ بها. في الواقع ، لا يمكن تحويل كيان المجال إلى كيان قاعدة بيانات بهذه الطريقة. فقط تخيل أننا نريد أيضًا تنزيل البيانات من شبكة. يمكننا استخدام المزيد من الفئات للتعامل مع استجابات الشبكة - تحويل كياناتنا البسيطة لجعلها تعمل مع قاعدة بيانات وشبكة. هذا هو أقصر طريق إلى كارثة مستقبلية (والبكاء ، "من كتب هذا الرمز بحق الجحيم؟"). نحتاج إلى فئات كيانات منفصلة لكل نوع تخزين بيانات نستخدمه. لا يكلف الكثير ، لذلك دعونا نحدد كيانات الغرفة بشكل صحيح:

 @Entity data class UserEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "isActive") var isActive: Boolean = false ) @Entity(foreignKeys = [ ForeignKey(entity = UserEntity::class, parentColumns = [ "id" ], childColumns = [ "userId" ], onDelete = CASCADE) ]) data class UserLocationEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "latitude") var latitude: Double, @ColumnInfo(name = "longitude") var longitude: Double, @ColumnInfo(name = "time") var time: Long, @ColumnInfo(name = "userId") var userId: Long )

كما ترى ، فهي تقريبًا نفس كيانات المجال ، لذلك هناك إغراء كبير لدمجها. هذا مجرد حادث - مع وجود بيانات أكثر تعقيدًا ، سيكون التشابه أصغر.

بعد ذلك ، يتعين علينا تنفيذ UserDAO و UserLocationsDAO و AppDatabase وأخيراً تطبيقات IUsersRepository و ILocationsRepository . توجد مشكلة صغيرة هنا - يجب أن ILocationsRepository بإرجاع UserLocation ، ولكنه يتلقى UserLocationEntity من قاعدة البيانات. الشيء نفسه ينطبق على الفئات ذات الصلة User . في الاتجاه المعاكس ، نقوم بتمرير UserLocation عندما تتطلب قاعدة البيانات UserLocationEntity . لحل هذه المشكلة ، نحتاج إلى Mappers بين كيانات المجال والبيانات الخاصة بنا. لقد استخدمت إحدى ميزات Kotlin المفضلة - الإضافات. لقد أنشأت ملفًا باسم Mapper.kt ، ووضعت جميع طرق التعيين بين الفئات هناك (بالطبع ، يوجد في طبقة النموذج - لا يحتاجها المجال):

 fun User.toEntity() = UserEntity(id?.toLong(), name, isActive) fun UserEntity.toUser() = User(this.id?.toInt(), name, isActive) fun UserLocation.toEntity() = UserLocationEntity(id?.toLong(), latitude, longitude, time, userId.toLong()) fun UserLocationEntity.toUserLocation() = UserLocation(id?.toInt(), latitude, longitude, time, userId.toInt())

الكذبة الصغيرة التي ذكرتها من قبل تتعلق بكيانات المجال. لقد كتبت أنهم لا يعرفون شيئًا عن Android ، لكن هذا ليس صحيحًا تمامًا. لقد أضفت تعليقًا توضيحيًا @Parcelize إلى كيان User وقمت بتوسيع Parcelable هناك ، مما يجعل من الممكن تمرير الكيان إلى جزء. بالنسبة إلى الهياكل الأكثر تعقيدًا ، يجب أن نوفر فئات البيانات الخاصة بطبقة العرض ، وإنشاء مصممي الخرائط مثل بين المجال ونماذج البيانات. تعد إضافة Parcelable إلى كيان المجال مخاطرة صغيرة تجرأت على تحملها - وأنا على دراية بذلك ، وفي حالة حدوث أي تغييرات في كيان User ، سأقوم بإنشاء فئات بيانات منفصلة للعرض التقديمي وإزالة Parcelable من طبقة المجال.

آخر شيء يجب القيام به هو تغيير وحدة حقن التبعية الخاصة بنا لتوفير تنفيذ Repository الذي تم إنشاؤه حديثًا بدلاً من MemoryRepository السابقة. بعد إنشاء التطبيق وتشغيله ، يمكننا الانتقال إلى PM لإظهار تطبيق العمل بقاعدة بيانات الغرفة. يمكننا أيضًا إبلاغ رئيس الوزراء بأن إضافة شبكة لن تستغرق الكثير من الوقت ، وأننا منفتحون على أي مكتبة تخزين تريدها الإدارة. يمكنك التحقق من الملفات التي تم تغييرها — فقط تلك الموجودة في طبقة النموذج. هندستنا أنيقة حقا! يمكن بناء كل نوع تخزين تالٍ بنفس الطريقة ، فقط عن طريق توسيع مستودعاتنا وتوفير عمليات التنفيذ المناسبة. بالطبع ، قد يتضح أننا بحاجة إلى مصادر بيانات متعددة ، مثل قاعدة بيانات وشبكة. ماذا بعد؟ لا شيء له ، علينا فقط إنشاء ثلاثة تطبيقات للمستودع - واحد للشبكة ، وواحد لقاعدة البيانات ، وواحد رئيسي ، حيث سيتم تحديد مصدر البيانات الصحيح (على سبيل المثال ، إذا كان لدينا شبكة ، فقم بالتحميل من الشبكة ، وإذا لم يكن كذلك ، فقم بالتحميل من قاعدة بيانات).

يمكنك التحقق من التطبيق لهذه المرحلة على GitHub باستخدام العلامة architecture_v2.

لذلك ، أوشك اليوم على الانتهاء - فأنت جالس أمام الكمبيوتر مع فنجان من القهوة ، والتطبيق جاهز لإرساله إلى Google Play ، عندما يأتي إليك مدير المشروع فجأة ويسأل "هل يمكنك إضافة ميزة يمكن حفظ الموقع الحالي للمستخدم من GPS؟ "

التغيير 2

كل شيء يتغير ... خاصة البرنامج. هذا هو السبب في أننا نحتاج إلى كود نظيف وبنية نظيفة. ومع ذلك ، حتى أنظف الأشياء يمكن أن تكون قذرة إذا كنا نقوم بالبرمجة دون تفكير. تتمثل الفكرة الأولى عند تنفيذ الحصول على موقع من GPS في إضافة كل التعليمات البرمجية المدركة للموقع في النشاط وتشغيلها في SaveLocationDialogFragment وإنشاء موقع UserLocation جديد بالبيانات المقابلة. قد تكون هذه أسرع طريقة. ولكن ماذا لو أتى إلينا رئيس الوزراء المجنون وطلب منا تغيير الحصول على الموقع من GPS إلى مزود آخر (على سبيل المثال ، شيء مثل Bluetooth أو الشبكة)؟ ستخرج التغييرات عن السيطرة قريبًا. كيف يمكننا أن نفعل ذلك بطريقة نظيفة؟

موقع المستخدم هو البيانات. والحصول على موقع هو حالة UseCase — لذا أعتقد أن مجالنا وطبقات نموذجنا يجب أن تشارك هنا أيضًا. وبالتالي ، لدينا UseCase واحد آخر لتنفيذه - GetCurrentLocation . نحتاج أيضًا إلى شيء يوفر لنا موقعًا - واجهة ILocationProvider ، لجعل UseCase مستقلاً عن التفاصيل مثل مستشعر GPS:

 interface ILocationProvider { fun getLocation(): OneOf<Failure, SimpleLocation> fun cancel() } class GetCurrentLocation @Inject constructor(private val locationProvider: ILocationProvider) : UseCase<SimpleLocation, UseCase.NoParams>() { override suspend fun run(params: NoParams): OneOf<Failure, SimpleLocation> = locationProvider.getLocation() override fun cancel() { super.cancel() locationProvider.cancel() } }

يمكنك أن ترى أن لدينا طريقة إضافية واحدة هنا - إلغاء. هذا لأننا نحتاج إلى طريقة لإلغاء تحديثات موقع GPS. تنفيذ Provider لدينا ، المحدد في طبقة النموذج ، يذهب هنا:

 class GPSLocationProvider constructor(var activity: Activity) : ILocationProvider { private var locationManager: LocationManager? = null private var locationListener: GPSLocationListener? = null override fun getLocation(): OneOf<Failure, SimpleLocation> = runBlocking { val grantedResult = getLocationPermissions() if (grantedResult.isError) { val error = (grantedResult as OneOf.Error<Failure>).error OneOf.Error(error) } else { getLocationFromGPS() } } private suspend fun getLocationPermissions(): OneOf<Failure, Boolean> = suspendCoroutine { Dexter.withActivity(activity) .withPermission(Manifest.permission.ACCESS_FINE_LOCATION) .withListener(PermissionsCallback(it)) .check() } private suspend fun getLocationFromGPS(): OneOf<Failure, SimpleLocation> = suspendCoroutine { locationListener?.unsubscribe() locationManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager?.let { manager -> locationListener = GPSLocationListener(manager, it) launch(UI) { manager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0.0f, locationListener) } } } override fun cancel() { locationListener?.unsubscribe() locationListener = null locationManager = null } }

هذا المزود على استعداد للعمل مع coroutines Kotlin. إذا كنت تتذكر ، يتم استدعاء طريقة تشغيل UseCase في سلسلة رسائل في الخلفية - لذلك علينا التأكد من وضع علامات على سلاسل الرسائل الخاصة بنا بشكل صحيح. كما ترى ، يجب أن نجتاز نشاطًا هنا - من المهم للغاية إلغاء التحديثات وإلغاء التسجيل من المستمعين عندما لا نعد بحاجة إليهم لتجنب تسرب الذاكرة. نظرًا لأنه يطبق ILocationProvider ، يمكننا بسهولة تعديله في المستقبل إلى مزود آخر. يمكننا أيضًا اختبار التعامل مع الموقع الحالي بسهولة (تلقائيًا أو يدويًا) ، حتى بدون تمكين نظام تحديد المواقع العالمي (GPS) في الهاتف - كل ما يتعين علينا القيام به هو استبدال التطبيق لإرجاع موقع تم إنشاؤه عشوائيًا. لإنجاحه ، يتعين علينا إضافة UseCase الذي تم إنشاؤه حديثًا إلى LocationsViewModel . يجب أن يكون لدى ViewModel ، بدوره ، طريقة جديدة ، getCurrentLocation ، والتي ستستدعي بالفعل حالة الاستخدام. مع القليل من التغييرات الصغيرة في واجهة المستخدم للاتصال به وتسجيل GPSProvider في Dagger - وفويلا ، انتهى تطبيقنا!

ملخص

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