Android Temiz Mimarisinin Avantajlarını Keşfedin

Yayınlanan: 2022-03-11

Neyi tercih edersiniz: berbat bir mimariye sahip çok iyi çalışan bir uygulamaya yeni bir özellik eklemek mi, yoksa iyi tasarlanmış ama sorunlu Android uygulamasındaki bir hatayı düzeltmek mi? Şahsen ben kesinlikle ikinci seçeneği seçerdim. Her sınıftaki her şeyin tüm bağımlılıkları göz önüne alındığında, basit bir özellik bile olsa yeni bir özellik eklemek, bir uygulamada çok zahmetli olabilir. Bir proje yöneticisinin benden küçük bir özellik eklememi istediği Android projelerimden birini hatırlıyorum - veri indirmek ve yeni bir ekranda görüntülemek gibi bir şey. Yeni bir iş bulan meslektaşlarımdan birinin yazdığı bir uygulamaydı. Özellik, bir iş gününün yarısından fazlasını almamalıdır. çok iyimserdim…

Uygulamanın nasıl çalıştığına, hangi modüllerin bulunduğuna ve birbirleriyle nasıl iletişim kurduklarına dair yedi saatlik araştırmadan sonra, özelliğin bazı deneme uygulamalarını yaptım. Cehennemdi. Veri modelindeki küçük bir değişiklik, oturum açma ekranında büyük bir değişikliği zorunlu kıldı. Bir ağ isteği eklemek, hemen hemen tüm ekranların ve GodOnlyKnowsWhatThisClassDoes sınıfının uygulanmasında değişiklikler gerektiriyordu. Düğme rengi değişiklikleri, verileri veritabanına kaydederken garip davranışlara veya toplam uygulama çökmesine neden oldu. Ertesi günün yarısında proje yöneticime "Özelliği uygulamak için iki yolumuz var. İlk olarak, üzerinde üç gün daha harcayabilirim ve sonunda onu çok kirli bir şekilde uygulayacağım ve sonraki her özelliğin veya hata düzeltmenin uygulama süresi katlanarak artacak. Veya uygulamayı yeniden yazabilirim. Bu benim iki veya üç haftamı alacak ama gelecekteki uygulama değişiklikleri için zaman kazandıracağız.” Neyse ki ikinci seçeneği kabul etti. Bir uygulamada (çok küçük bir yazılımda bile) iyi yazılım mimarisinin neden önemli olduğu konusunda şüphelerim varsa, bu uygulama onları tamamen ortadan kaldırdı. Ancak bu tür sorunlardan kaçınmak için hangi Android mimari modelini kullanmalıyız?

Bu yazıda size bir Android uygulamasında temiz bir mimari örneği göstermek istiyorum. Ancak bu kalıbın ana fikirleri her platforma ve dile uyarlanabilir. İyi mimari, platform, dil, veritabanı sistemi, girdi veya çıktı gibi ayrıntılardan bağımsız olmalıdır.

Örnek Uygulama

Aşağıdaki özelliklerle konumumuzu kaydetmek için basit bir Android uygulaması oluşturacağız:

  • Kullanıcı bir adla bir hesap oluşturabilir.
  • Kullanıcı hesap adını düzenleyebilir.
  • Kullanıcı hesabı silebilir.
  • Kullanıcı aktif hesabı seçebilir.
  • Kullanıcı konumu kaydedebilir.
  • Kullanıcı, bir kullanıcı için konum listesini görebilir.
  • Kullanıcı, kullanıcıların bir listesini görebilir.

Temiz Mimari

Katmanlar, temiz bir mimarinin ana çekirdeğidir. Uygulamamızda üç katman kullanacağız: sunum, etki alanı ve model. Her katman birbirinden ayrılmalı ve diğer katmanlar hakkında bilgi sahibi olması gerekmemelidir. Kendi dünyasında var olmalı ve en fazla iletişim kurmak için küçük bir arayüzü paylaşmalıdır.

Katman sorumlulukları:

  • Etki Alanı: Uygulamamızın iş kurallarını içerir. Uygulamamızın özelliklerini yansıtan kullanım durumları sağlamalıdır.
  • Sunum: Verileri kullanıcıya sunar ve ayrıca kullanıcı adı gibi gerekli verileri toplar. Bu bir tür girdi/çıktıdır.
  • Model: Uygulamamız için veri sağlar. Dış kaynaklardan veri elde etmek ve bunları veritabanına, bulut sunucusuna vb. kaydetmekten sorumludur.

Hangi katmanlar diğerleri hakkında bilgi sahibi olmalıdır? Cevabı almanın en basit yolu, değişiklikleri düşünmektir. Sunum katmanını ele alalım—kullanıcıya bir şey sunacağız. Sunumda bir değişiklik yaparsak, model katmanında da değişiklik yapmalı mıyız? Kullanıcının adını ve son konumunu içeren bir “Kullanıcı” ekranımız olduğunu hayal edin. Kullanıcının sadece bir yerine son iki konumunu sunmak istiyorsak modelimiz etkilenmemelidir. İlk prensibimiz var: Sunum katmanı, model katmanı hakkında bilgi sahibi değil.

Ve tam tersi—model katmanı sunum katmanı hakkında bilgi sahibi olmalı mı? Yine—hayır, çünkü örneğin bir veritabanından bir ağa veri kaynağını değiştirirsek, bu UI'de hiçbir şeyi değiştirmemelidir (buraya bir yükleyici eklemeyi düşündüyseniz - evet, ancak bir UI yükleyicimiz de olabilir) bir veritabanı kullanırken). Yani iki katman tamamen ayrıdır. Harika!

Etki alanı katmanı ne olacak? Tüm ana iş mantığını içerdiği için en önemlisidir. Bu, verilerimizi model katmanına geçirmeden veya kullanıcıya sunmadan önce işlemek istediğimiz yerdir. Diğer katmanlardan bağımsız olmalıdır—veritabanı, ağ veya kullanıcı arayüzü hakkında hiçbir şey bilmez. Bu çekirdek olduğundan, diğer katmanlar yalnızca bununla iletişim kuracaktır. Neden bunu tamamen bağımsız hale getirmek istiyoruz? İş kuralları muhtemelen UI tasarımlarından veya veritabanındaki veya ağ deposundaki bir şeyden daha az değişecektir. Sağlanan bazı arayüzler aracılığıyla bu katmanla iletişim kuracağız. Herhangi bir somut model veya UI uygulaması kullanmaz. Bunlar ayrıntılardır ve unutmayın; ayrıntılar değişir. İyi bir mimari ayrıntılara bağlı değildir.

Şimdilik yeterli teori. Hadi kodlamaya başlayalım! Bu makale kodun etrafında döner, bu nedenle - daha iyi anlamak için - kodu GitHub'dan indirmeli ve içinde ne olduğunu kontrol etmelisiniz. Oluşturulan üç Git etiketi vardır: makalenin bölümlerine karşılık gelen mimari_v1, mimari_v2 ve mimari_v3.

Uygulama Teknolojisi

Uygulamada, bağımlılık enjeksiyonu için Kotlin ve Dagger 2 kullanıyorum. Burada ne Kotlin ne de Dagger 2 gerekli değil, ancak işleri çok daha kolaylaştırıyor. RxJava'yı (ne de RxKotlin'i) kullanmadığıma şaşırabilirsiniz, ancak burada kullanışlı bulmadım ve herhangi bir kütüphaneyi sadece üstte olduğu ve birileri bunun şart olduğunu söylediği için kullanmayı sevmiyorum. Dediğim gibi - dil ve kütüphaneler ayrıntılardır, yani istediğinizi kullanabilirsiniz. Bazı Android birim test kitaplıkları da kullanılır: JUnit, Robolectric ve Mockito.

İhtisas

Android uygulama mimarisi tasarımımızda en önemli katman alan katmanıdır. Onunla başlayalım. İş mantığımız ve diğer katmanlarla iletişim kurmak için arayüzler burada olacaktır. Ana çekirdek, kullanıcının uygulamamızla neler yapabileceğini yansıtan UseCase s'dir. Onlar için bir soyutlama hazırlayalım:

 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 }

Burada Kotlin'in eşyordamlarını kullanmaya karar verdim. Her UseCase , verileri sağlamak için bir çalıştırma yöntemi uygulamak zorundadır. Bu yöntem bir arka plan iş parçacığında çağrılır ve bir sonuç alındıktan sonra UI iş parçacığında teslim edilir. Döndürülen tür OneOf<F, T> - verilerle bir hata veya başarı döndürebiliriz:

 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) } }

Etki alanı katmanının kendi varlıklarına ihtiyacı vardır, bu nedenle bir sonraki adım onları tanımlamaktır. Şimdilik iki varlığımız var: User ve 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)

Artık hangi verilerin döndürüleceğini bildiğimize göre, veri sağlayıcılarımızın arayüzlerini bildirmemiz gerekiyor. Bunlar IUsersRepository ve ILocationsRepository olacaktır. Model katmanında uygulanmaları gerekir:

 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> }

Bu eylem dizisi, uygulama için gerekli verileri sağlamak için yeterli olmalıdır. Bu aşamada verilerin nasıl saklanacağına biz karar vermiyoruz - bu, bağımsız olmak istediğimiz bir detay. Şimdilik, etki alanı katmanımız Android'de olduğunu bile bilmiyor. Bu durumu korumaya çalışacağız (Bir nevi. Daha sonra açıklayacağım).

Son (veya neredeyse son) adım, sunum verileri tarafından kullanılacak UseCase 'lerimiz için uygulamaları tanımlamaktır. Hepsi çok basittir (tıpkı bizim uygulamamız ve verilerimiz gibi)—işlemleri depodan uygun bir yöntemi çağırmakla sınırlıdır, örneğin:

 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 soyutlaması, UseCases test edilmesini çok kolaylaştırır - bir ağ veya veritabanı ile ilgilenmemize gerek yoktur. Herhangi bir şekilde alay edilebilir, bu nedenle birim testlerimiz diğer alakasız sınıfları değil gerçek kullanım durumlarını test edecektir. Bu, birim testlerimizi basit ve hızlı hale getirecek:

 @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) } }

Şimdilik, etki alanı katmanı tamamlandı.

modeli

Bir Android geliştiricisi olarak, muhtemelen veri depolamak için yeni Android kitaplığı olan Room'u seçeceksiniz. Ancak proje yöneticisinin, yönetim Oda, Bölge ve bazı yeni, süper hızlı depolama kitaplıkları arasında karar vermeye çalıştığı için veritabanıyla ilgili kararı erteleyip erteleyemeyeceğinizi sorduğunu düşünelim. UI ile çalışmaya başlamak için bazı verilere ihtiyacımız var, bu yüzden şimdilik sadece hafızada tutacağız:

 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) } }

Sunum

İki yıl önce Android için çok iyi bir uygulama yapısı olarak MVP hakkında bir yazı yazmıştım. Google, Android uygulama geliştirmeyi çok daha kolay hale getiren harika Mimari Bileşenlerini duyurduğunda, MVP'ye artık ihtiyaç kalmamıştır ve MVVM ile değiştirilebilir; bununla birlikte, bu kalıptan bazı fikirler hala çok faydalıdır - aptal görüşler hakkında olan gibi. Yalnızca verileri görüntülemeyi önemsemelidirler. Bunu başarmak için ViewModel ve LiveData'yı kullanacağız.

Uygulamamızın tasarımı çok basittir; iki menü girişinin locations parçalarını veya users parçalarını gösterdiği, alt gezinme özelliğine sahip bir etkinlik. Bu görünümlerde, etki alanı katmanından UseCase s kullanan ve iletişimi düzenli ve basit tutan ViewModels kullanıyoruz. Örneğin, 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'e aşina olmayanlar için küçük bir açıklama—verilerimiz konumlar değişkeninde saklanır. getLocations kullanım örneğinden veri aldığımızda, bunlar LiveData değerine iletilir. Bu değişiklik, gözlemcilere tepki verebilmeleri ve verilerini güncelleyebilmeleri için bildirimde bulunacaktır. Bir parçadaki veriler için bir gözlemci ekliyoruz:

 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() } }

Her konum değişikliğinde, yeni verileri bir geri dönüşümcü görünümüne atanmış bir bağdaştırıcıya iletiyoruz ve bu, verileri bir geri dönüşümcü görünümünde göstermek için normal Android akışının gittiği yerdir.

Görünümlerimizde ViewModel kullandığımız için davranışlarının test edilmesi de kolaydır; yalnızca ViewModel'lerle alay edebiliriz ve veri kaynağı, ağ veya diğer faktörlerle ilgilenmeyiz:

 @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" } }

Sunum katmanının da net sınırlarla daha küçük katmanlara ayrıldığını fark edebilirsiniz. activities , fragments , ViewHolders vb. gibi görünümler yalnızca verilerin görüntülenmesinden sorumludur. Yalnızca ViewModel katmanının farkındadırlar ve yalnızca bunu kullanıcıları ve konumları almak veya göndermek için kullanırlar. Etki alanı ile iletişim kuran bir ViewModel'dir. ViewModel uygulamaları, görünüm için UseCases'in etki alanı için olduğu ile aynıdır. Başka bir deyişle, temiz mimari bir soğan gibidir - katmanları vardır ve katmanların da katmanları olabilir.

Bağımlılık Enjeksiyonu

Mimarimiz için tüm sınıfları yarattık, ancak yapılacak bir şey daha var - her şeyi birbirine bağlayan bir şeye ihtiyacımız var. Sunum, etki alanı ve model katmanları temiz tutulur, ancak kirli olan ve her şey hakkında her şeyi bilecek bir modüle ihtiyacımız var - bu bilgi ile katmanlarımızı bağlayabilecek. Bunu yapmanın en iyi yolu, ortak tasarım modellerinden birini (SOLID'de tanımlanan temiz kod ilkelerinden biri) kullanmaktır - bizim için uygun nesneler yaratan ve bunları istenen bağımlılıklara enjekte eden bağımlılık enjeksiyonu. Burada Dagger 2 kullandım (projenin ortasında versiyonu 2.16 olarak değiştirdim, daha az kazan plakasına sahip), ama siz istediğiniz mekanizmayı kullanabilirsiniz. Son zamanlarda biraz Koin kütüphanesi ile oynadım ve bence denemeye değer. Burada kullanmak istedim, ancak test ederken ViewModels ile alay ederken çok fazla sorun yaşadım. Umarım bunları çabucak çözmenin bir yolunu bulurum - öyleyse, Koin ve Dagger 2'yi kullanırken bu uygulama için farklılıklar sunabilirim.

Bu aşama için uygulamayı Architecture_v1 etiketiyle GitHub'da kontrol edebilirsiniz.

Değişiklikler

Katmanlarımızı bitirdik, uygulamayı test ettik - her şey çalışıyor! Bir şey dışında, yine de PM'nin hangi veritabanını kullanmak istediğini bilmemiz gerekiyor. Size geldiklerini ve yönetimin Room'u kullanmayı kabul ettiğini ancak gelecekte en yeni, süper hızlı kitaplığı kullanma olanağına sahip olmak istediklerini söylediklerini varsayın, bu nedenle olası değişiklikleri aklınızda tutmanız gerekir. Ayrıca, paydaşlardan biri verilerin bir bulutta saklanıp saklanamayacağını sordu ve böyle bir değişikliğin maliyetini bilmek istiyor. Bu nedenle, mimarimizin iyi olup olmadığını ve sunumda veya etki alanı katmanında herhangi bir değişiklik yapmadan veri depolama sistemini değiştirip değiştiremeyeceğimizi kontrol etmenin zamanı geldi.

1'i değiştir

Room'u kullanırken ilk şey, bir veritabanı için varlıkları tanımlamaktır. Zaten bazılarımız var: User ve UserLocation . Tek yapmamız gereken @Entity ve @PrimaryKey gibi açıklamalar eklemek ve ardından onu bir veritabanı ile model katmanımızda kullanabiliriz. Harika! Bu, tutmak istediğimiz tüm mimari kuralları kırmanın mükemmel bir yoludur. Aslında, etki alanı varlığı bu şekilde bir veritabanı varlığına dönüştürülemez. Verileri bir ağdan da indirmek istediğimizi hayal edin. Ağ yanıtlarını işlemek için biraz daha sınıf kullanabiliriz; basit varlıklarımızı bir veritabanı ve ağ ile çalışmalarını sağlayacak şekilde dönüştürebiliriz. Gelecekteki bir felakete giden en kısa yol budur (ve “Bu kodu kim yazdı?” diye bağırır). Kullandığımız her veri depolama türü için ayrı varlık sınıflarına ihtiyacımız var. Çok maliyetli değil, o yüzden Room varlıklarını doğru tanımlayalım:

 @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 )

Gördüğünüz gibi, neredeyse alan varlıklarıyla aynılar, bu yüzden onları birleştirmek için büyük bir cazibe var. Bu sadece bir tesadüftür - daha karmaşık verilerle benzerlik daha küçük olacaktır.

Ardından, AppDatabase olan UserDAO ve UserLocationsDAO ve son olarak IUsersRepository ve ILocationsRepository uygulamalarını uygulamamız gerekiyor. Burada küçük bir sorun ILocationsRepository bir UserLocation döndürmelidir, ancak veritabanından bir UserLocationEntity alır. Aynısı User ile ilgili sınıflar için de geçerlidir. Ters yönde, veritabanı UserLocation gerektirdiğinde UserLocationEntity . Bunu çözmek için etki alanımız ve veri varlıklarımız arasında Mappers ihtiyacımız var. En sevdiğim Kotlin özelliklerinden birini, uzantıları kullandım. Mapper.kt adında bir dosya oluşturdum ve oradaki sınıflar arasında eşleştirme için tüm yöntemleri koydum (elbette, model katmanındadır - etki alanının buna ihtiyacı yoktur):

 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())

Daha önce bahsettiğim küçük yalan, alan varlıklarıyla ilgili. Android hakkında hiçbir şey bilmediklerini yazdım ama bu tamamen doğru değil. User varlığına @Parcelize notu ekledim ve Parcelable orada genişleterek varlığı bir parçaya geçirmeyi mümkün kıldım. Daha karmaşık yapılar için, görünüm katmanının kendi veri sınıflarını sağlamalı ve etki alanı ve veri modelleri gibi eşleştiriciler oluşturmalıyız. Etki alanı varlığına Parcelable eklemek, almaya cesaret ettiğim küçük bir riskti - Bunun farkındayım ve herhangi bir User varlığında değişiklik olması durumunda sunum için ayrı veri sınıfları oluşturacağım ve Parcelable etki alanı katmanından kaldıracağım.

Yapılması gereken son şey, önceki MemoryRepository yerine yeni oluşturulan Repository uygulamasını sağlamak için bağımlılık ekleme modülümüzü değiştirmektir. Uygulamayı oluşturup çalıştırdıktan sonra, çalışan uygulamayı bir Room veritabanıyla göstermek için PM'ye gidebiliriz. Ayrıca PM'ye bir ağ eklemenin çok fazla zaman almayacağını ve yönetimin istediği herhangi bir depolama kitaplığına açık olduğumuzu bildirebiliriz. Hangi dosyaların değiştirildiğini kontrol edebilirsiniz - yalnızca model katmanındakiler. Mimarimiz gerçekten temiz! Sonraki her depolama türü, yalnızca depolarımızı genişleterek ve uygun uygulamaları sağlayarak aynı şekilde oluşturulabilir. Tabii ki, bir veritabanı ve bir ağ gibi birden fazla veri kaynağına ihtiyacımız olduğu ortaya çıkabilir. Sonra ne? Buna bir şey yok, sadece üç depo uygulaması oluşturmamız gerekecek - biri ağ için, biri veritabanı için ve doğru veri kaynağının seçileceği ana bir tane (örneğin, bir ağımız varsa, ağ ve değilse, bir veritabanından yükleyin).

Bu aşama için uygulamayı Architecture_v2 etiketiyle GitHub'da inceleyebilirsiniz.

Gün bitmek üzere; bilgisayarınızın başında bir fincan kahve ile oturuyorsunuz, uygulama Google Play'e gönderilmeye hazır, aniden proje yöneticisi size gelip “Bir özellik ekleyebilir misiniz? kullanıcının mevcut konumunu GPS'den kaydedebilir mi?"

2'yi değiştir

Her şey değişir… özellikle yazılım. Bu yüzden temiz koda ve temiz mimariye ihtiyacımız var. Ancak, düşünmeden kod yazıyorsak, en temiz şeyler bile kirli olabilir. GPS'den bir konum almayı uygularken ilk düşünce, aktivitedeki tüm konuma duyarlı kodu eklemek, onu SaveLocationDialogFragment çalıştırmak ve ilgili verilerle yeni bir UserLocation oluşturmak olacaktır. Bu en hızlı yol olabilir. Ama ya çılgın Başbakanımız bize gelip GPS'ten başka bir sağlayıcıya (örneğin, Bluetooth veya ağ gibi bir şey) konum almamızı değiştirmemizi isterse? Değişiklikler yakında kontrolden çıkacaktı. Temiz bir şekilde nasıl yapabiliriz?

Kullanıcı konumu veridir. Ve bir konum elde etmek bir UseCase - bu yüzden etki alanı ve model katmanlarımızın da burada yer alması gerektiğini düşünüyorum. Böylece, uygulamak için bir UseCase daha var: GetCurrentLocation . Ayrıca, UseCase GPS sensörü gibi ayrıntılardan bağımsız hale getirmek için bize bir konum sağlayacak bir ILocationProvider arayüzüne ihtiyacımız var:

 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() } }

Burada ek bir yöntemimiz olduğunu görebilirsiniz: İptal. Bunun nedeni, GPS konum güncellemelerini iptal etmenin bir yoluna ihtiyacımız olmasıdır. Model katmanında tanımlanan Provider uygulamamız buraya gelir:

 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 } }

Bu sağlayıcı, Kotlin coroutines ile çalışmaya hazırdır. Hatırlarsanız, UseCase s' run yöntemi bir arka plan iş parçacığında çağrılır - bu nedenle iş parçacıklarımızı emin ve doğru bir şekilde işaretlemeliyiz. Gördüğünüz gibi, burada bir etkinliği geçmemiz gerekiyor—hafıza sızıntılarını önlemek için artık ihtiyacımız olmadığında güncellemeleri iptal etmek ve dinleyicilerin kaydını silmek çok önemlidir. ILocationProvider uyguladığı için, gelecekte başka bir sağlayıcıya kolayca değiştirebiliriz. Ayrıca, telefonumuzdaki GPS'i etkinleştirmeden bile mevcut konumun ele alınmasını (otomatik veya manuel olarak) kolayca test edebiliriz - tek yapmamız gereken rastgele oluşturulmuş bir konum döndürmek için uygulamayı değiştirmek. Çalışması için, yeni oluşturulan UseCase LocationsViewModel eklemeliyiz. ViewModel, sırayla, kullanım durumunu çağıracak olan getCurrentLocation adlı yeni bir yönteme sahip olmalıdır. Onu çağırmak ve GPSProvider'ı Dagger'a kaydetmek için yalnızca birkaç küçük kullanıcı arayüzü değişikliğiyle—ve işte, uygulamamız bitti!

Özet

Bakımı, testi ve değişimi kolay bir Android uygulamasını nasıl geliştirebileceğimizi göstermeye çalışıyordum. Ayrıca anlaşılması kolay olmalı - işinize yeni biri gelirse, veri akışını veya yapıyı anlamakta sorun yaşamamalıdır. Mimarinin temiz olduğunun farkındalarsa, kullanıcı arayüzündeki değişikliklerin modeldeki hiçbir şeyi etkilemeyeceğinden ve yeni bir özellik eklemenin tahmin edilenden fazlasını almayacağından emin olabilirler. Ama bu yolculuğun sonu değil. Güzel yapılandırılmış bir uygulamamız olsa bile, "sadece bir an için, sadece çalışmak için" dağınık kod değişiklikleriyle onu kırmak çok kolaydır. Unutmayın, “şimdilik” diye bir kod yoktur. Kurallarımızı ihlal eden her kod, kod tabanında kalıcı olabilir ve gelecekteki, daha büyük kırılmaların kaynağı olabilir. Sadece bir hafta sonra bu koda gelirseniz, birileri bu kodda bazı güçlü bağımlılıklar uygulamış gibi görünecek ve bunu çözmek için uygulamanın diğer birçok bölümünü incelemeniz gerekecek. İyi bir kod mimarisi, yalnızca projenin başında değil, aynı zamanda Android uygulamasının kullanım ömrünün herhangi bir bölümü için de bir zorluktur. Kodu düşünmek ve kontrol etmek, bir şeylerin değişeceği her zaman hesaba katılmalıdır. Bunu hatırlamak için, örneğin Android mimari diyagramınızı yazdırabilir ve asabilirsiniz. Ayrıca, etki alanı modülünün diğerlerinden haberdar olmadığı ve sunum ve model modüllerinin birbirini kullanmadığı üç Gradle modülüne ayırarak katmanların bağımsızlığını biraz zorlayabilirsiniz. Ancak bu bile, uygulama kodundaki karışıklığın intikamını en beklemediğimiz anda alacağı bilincinin yerini alamaz.