Descubra os benefícios da arquitetura limpa do Android
Publicados: 2022-03-11O que você prefere: adicionar um novo recurso a um aplicativo que funciona muito bem com uma arquitetura horrível ou corrigir um bug no aplicativo Android bem arquitetado, mas com bugs? Pessoalmente, eu definitivamente escolheria a segunda opção. Adicionar um novo recurso, mesmo que simples, pode se tornar muito trabalhoso em um aplicativo, considerando todas as dependências de tudo em todas as classes. Lembro-me de um dos meus projetos Android, em que um gerente de projeto me pediu para adicionar um pequeno recurso, algo como baixar dados e exibi-los em uma nova tela. Era um aplicativo escrito por um dos meus colegas que encontrou um novo emprego. O recurso não deve demorar mais do que meio dia útil. Eu estava muito otimista…
Após sete horas de investigação sobre como o aplicativo funciona, quais módulos existem e como eles se comunicam, fiz algumas implementações de teste do recurso. Foi um inferno. Uma pequena mudança no modelo de dados forçou uma grande mudança na tela de login. A adição de uma solicitação de rede exigiu alterações de implementação de quase todas as telas e da classe GodOnlyKnowsWhatThisClassDoes
. As alterações de cor do botão causaram um comportamento estranho ao salvar os dados no banco de dados ou uma falha total do aplicativo. No meio do dia seguinte, eu disse ao meu gerente de projeto: “Temos duas maneiras de implementar o recurso. Primeiro, posso passar mais três dias nele e, finalmente, vou implementá-lo de uma maneira muito suja, e o tempo de implementação de cada próximo recurso ou correção de bugs crescerá exponencialmente. Ou posso reescrever o aplicativo. Isso levará duas ou três semanas, mas economizaremos tempo para as futuras alterações do aplicativo.”” Felizmente, ele concordou com a segunda opção. Se eu já tive dúvidas por que uma boa arquitetura de software em um aplicativo (mesmo um muito pequeno) é importante, este aplicativo as dissipou totalmente. Mas qual padrão de arquitetura Android devemos usar para evitar esses problemas?
Neste artigo, gostaria de mostrar um exemplo de arquitetura limpa em um aplicativo Android. As ideias principais desse padrão, no entanto, podem ser adaptadas a todas as plataformas e linguagens. Uma boa arquitetura deve ser independente de detalhes como plataforma, linguagem, sistema de banco de dados, entrada ou saída.
Aplicativo de exemplo
Vamos criar um aplicativo Android simples para registrar nossa localização com os seguintes recursos:
- O usuário pode criar uma conta com um nome.
- O usuário pode editar o nome da conta.
- O usuário pode excluir a conta.
- O usuário pode selecionar a conta ativa.
- O usuário pode salvar a localização.
- O usuário pode ver a lista de locais para um usuário.
- O usuário pode ver uma lista de usuários.
Arquitetura limpa
As camadas são o núcleo principal de uma arquitetura limpa. Em nosso aplicativo, usaremos três camadas: apresentação, domínio e modelo. Cada camada deve ser separada e não deve precisar saber sobre outras camadas. Deve existir em seu próprio mundo e, no máximo, compartilhar uma pequena interface para se comunicar.
Responsabilidades da camada:
- Domínio: Contém as regras de negócio do nosso aplicativo. Ele deve fornecer casos de uso que reflitam os recursos do nosso aplicativo.
- Apresentação: Apresenta os dados ao usuário e também coleta os dados necessários, como o nome de usuário. Este é um tipo de entrada/saída.
- Modelo: Fornece dados para nosso aplicativo. É responsável por obter dados de fontes externas e salvá-los no banco de dados, servidor em nuvem, etc.
Quais camadas devem saber sobre as outras? A maneira mais simples de obter a resposta é pensar em mudanças. Vamos pegar a camada de apresentação - vamos apresentar algo para o usuário. Se mudarmos algo na apresentação, devemos também fazer uma mudança em uma camada de modelo? Imagine que temos uma tela “Usuário” com o nome do usuário e sua última localização. Se quisermos apresentar os dois últimos locais do usuário em vez de apenas um, nosso modelo não deve ser afetado. Assim, temos o primeiro princípio: A camada de apresentação não conhece a camada de modelo.
E, ao contrário, a camada de modelo deve saber sobre a camada de apresentação? Novamente—não, porque se alterarmos, por exemplo, a fonte de dados de um banco de dados para uma rede, isso não deve alterar nada na interface do usuário (se você pensou em adicionar um carregador aqui—sim, mas também podemos ter um carregador de interface do usuário ao usar um banco de dados). Portanto, as duas camadas são completamente separadas. Excelente!
E a camada de domínio? É o mais importante porque contém toda a lógica de negócio principal. É aqui que queremos processar nossos dados antes de passá-los para a camada de modelo ou apresentá-los ao usuário. Ele deve ser independente de qualquer outra camada - ele não sabe nada sobre o banco de dados, a rede ou a interface do usuário. Como este é o núcleo, outras camadas se comunicarão apenas com esta. Por que queremos ter isso completamente independente? As regras de negócios provavelmente mudarão com menos frequência do que os designs de interface do usuário ou algo no banco de dados ou no armazenamento de rede. Nós nos comunicaremos com esta camada através de algumas interfaces fornecidas. Ele não usa nenhum modelo concreto ou implementação de interface do usuário. Estes são detalhes, e lembre-se: os detalhes mudam. Uma boa arquitetura não está limitada a detalhes.
Chega de teoria por enquanto. Vamos começar a codificar! Este artigo gira em torno do código, portanto, para melhor compreensão, você deve baixar o código do GitHub e verificar o que está dentro. Existem três tags Git criadas—architecture_v1, architecture_v2 e architecture_v3, que correspondem às partes do artigo.
Tecnologia de aplicativo
No aplicativo, uso Kotlin e Dagger 2 para injeção de dependência. Nem Kotlin nem Dagger 2 são necessários aqui, mas tornam as coisas muito mais fáceis. Você pode se surpreender que eu não uso RxJava (nem RxKotlin), mas não achei útil aqui, e não gosto de usar nenhuma biblioteca apenas porque está no topo e alguém diz que é uma obrigação. Como eu disse, linguagem e bibliotecas são detalhes, então você pode usar o que quiser. Algumas bibliotecas de teste de unidade do Android também são usadas: JUnit, Robolectric e Mockito.
Domínio
A camada mais importante em nosso projeto de arquitetura de aplicativo Android é a camada de domínio. Vamos começar com ele. É aqui que estarão nossa lógica de negócios e as interfaces para comunicação com outras camadas. O núcleo principal são os UseCase
s, que refletem o que o usuário pode fazer com nosso aplicativo. Vamos preparar uma abstração para eles:
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 }
Eu decidi usar as corrotinas do Kotlin aqui. Cada UseCase
precisa implementar um método run para fornecer os dados. Esse método é chamado em um thread em segundo plano e, depois que um resultado é recebido, ele é entregue no thread da interface do usuário. O tipo retornado é OneOf<F, T>
podemos retornar um erro ou sucesso com os dados:
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) } }
A camada de domínio precisa de suas próprias entidades, então o próximo passo é defini-las. Temos duas entidades por enquanto: User
e 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)
Agora que sabemos quais dados retornar, temos que declarar as interfaces de nossos provedores de dados. Eles serão IUsersRepository
e ILocationsRepository
. Eles devem ser implementados na camada de modelo:
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> }
Esse conjunto de ações deve ser suficiente para fornecer os dados necessários para o aplicativo. Neste estágio, não decidimos como os dados serão armazenados - esse é um detalhe do qual queremos ser independentes. Por enquanto, nossa camada de domínio nem sabe que está no Android. Vamos tentar manter esse estado (mais ou menos. explico depois).
O último (ou quase último) passo é definir implementações para nossos UseCase
s, que serão usados pelos dados de apresentação. Todos eles são muito simples (assim como nosso aplicativo e dados são simples) - suas operações são limitadas a chamar um método adequado do repositório, por exemplo:
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) }
A abstração do Repository
torna nossos UseCases
muito fáceis de testar - não precisamos nos preocupar com uma rede ou um banco de dados. Ele pode ser zombado de qualquer forma, então nossos testes de unidade testarão casos de uso reais e não outras classes não relacionadas. Isso tornará nossos testes de unidade simples e rápidos:
@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) } }
Por enquanto, a camada de domínio está finalizada.
Modelo
Como desenvolvedor Android, você provavelmente escolherá o Room, a nova biblioteca Android para armazenamento de dados. Mas vamos imaginar que o gerente de projeto perguntou se você pode adiar a decisão sobre o banco de dados porque o gerenciamento está tentando decidir entre Room, Realm e alguma nova biblioteca de armazenamento super rápida. Precisamos de alguns dados para começar a trabalhar com a interface do usuário, então vamos mantê-los na memória por enquanto:
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) } }
Apresentação
Dois anos atrás, escrevi um artigo sobre o MVP como uma estrutura de aplicativo muito boa para Android. Quando o Google anunciou os grandes componentes de arquitetura, que tornaram o desenvolvimento de aplicativos Android muito mais fácil, o MVP não é mais necessário e pode ser substituído pelo MVVM; no entanto, algumas ideias desse padrão ainda são muito úteis – como aquela sobre visões idiotas. Eles devem se preocupar apenas em exibir os dados. Para isso, usaremos ViewModel e LiveData.
O design do nosso aplicativo é muito simples - uma atividade com navegação inferior, na qual duas entradas de menu mostram o fragmento de locations
ou o fragmento de users
. Nessas visualizações usamos ViewModels, que por sua vez usam UseCase
s da camada de domínio, mantendo a comunicação limpa e simples. Por exemplo, aqui está 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 } }
Uma pequena explicação para quem não está familiarizado com ViewModels—nossos dados são armazenados na variável locais. Quando obtemos dados do caso de uso getLocations
, eles são passados para o valor LiveData
. Essa mudança notificará os observadores para que possam reagir e atualizar seus dados. Adicionamos um observador para os dados em um fragmento:
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() } }
Em cada mudança de local, apenas passamos os novos dados para um adaptador atribuído a uma visualização de reciclador – e é aí que vai o fluxo normal do Android para mostrar dados em uma visualização de reciclador.
Como usamos ViewModel em nossas visualizações, seu comportamento também é fácil de testar - podemos apenas simular os ViewModels e não nos importar com a fonte de dados, rede ou outros fatores:

@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" } }
Você pode notar que a camada de apresentação também é separada em camadas menores com bordas claras. Visualizações como activities
, fragments
, ViewHolders
, etc. são responsáveis apenas por exibir dados. Eles estão cientes apenas sobre a camada ViewModel - e usam apenas isso para obter ou enviar usuários e locais. É um ViewModel que se comunica com o domínio. As implementações de ViewModel são as mesmas para a visualização que os UseCases são para o domínio. Parafraseando, a arquitetura limpa é como uma cebola – tem camadas, e as camadas também podem ter camadas.
Injeção de dependência
Criamos todas as classes para nossa arquitetura, mas há mais uma coisa a fazer: precisamos de algo que conecte tudo junto. As camadas de apresentação, domínio e modelo são mantidas limpas, mas precisamos de um módulo que será o sujo e saberá tudo sobre tudo - com esse conhecimento, ele poderá conectar nossas camadas. A melhor maneira de fazer isso é usando um dos padrões de design comuns (um dos princípios de código limpo definidos no SOLID) — injeção de dependência, que cria objetos apropriados para nós e os injeta nas dependências desejadas. Eu usei o Dagger 2 aqui (no meio do projeto, mudei a versão para 2.16, que tem menos clichê), mas você pode usar qualquer mecanismo que quiser. Recentemente, joguei um pouco com a biblioteca Koin, e acho que também vale a pena tentar. Eu queria usá-lo aqui, mas tive muitos problemas em zombar dos ViewModels ao testar. Espero encontrar uma maneira de resolvê-los rapidamente - se assim for, posso apresentar diferenças para este aplicativo ao usar Koin and Dagger 2.
Você pode verificar o aplicativo para este estágio no GitHub com a tag architecture_v1.
Mudanças
Terminamos nossas camadas, testamos o aplicativo - tudo está funcionando! Exceto por uma coisa: ainda precisamos saber qual banco de dados nosso PM deseja usar. Suponha que eles vieram até você e disseram que a gerência concordou em usar o Room, mas eles ainda querem ter a possibilidade de usar a biblioteca mais nova e super-rápida no futuro, então você precisa manter as possíveis alterações em mente. Além disso, uma das partes interessadas perguntou se os dados podem ser armazenados em nuvem e quer saber o custo de tal mudança. Então, este é o momento de verificar se nossa arquitetura é boa e se podemos alterar o sistema de armazenamento de dados sem nenhuma alteração na apresentação ou na camada de domínio.
Alteração 1
A primeira coisa ao usar Room é definir entidades para um banco de dados. Já temos alguns: User
e UserLocation
. Tudo o que precisamos fazer é adicionar anotações como @Entity
e @PrimaryKey
, e então podemos usá-lo em nossa camada de modelo com um banco de dados. Excelente! Esta é uma excelente maneira de quebrar todas as regras de arquitetura que queríamos manter. Na verdade, a entidade de domínio não pode ser convertida em uma entidade de banco de dados dessa maneira. Imagine que também queremos baixar os dados de uma rede. Poderíamos usar mais algumas classes para lidar com as respostas da rede – convertendo nossas entidades simples para fazê-las funcionar com um banco de dados e uma rede. Esse é o caminho mais curto para uma catástrofe futura (e gritando: “Quem diabos escreveu esse código?”). Precisamos de classes de entidade separadas para cada tipo de armazenamento de dados que usamos. Não custa muito, então vamos definir as entidades Room corretamente:
@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 )
Como você pode ver, eles são quase iguais às entidades de domínio, então há uma grande tentação de mesclá-los. Isso é apenas um acidente - com dados mais complicados, a semelhança será menor.
Em seguida, temos que implementar o UserDAO
e o UserLocationsDAO
, nosso AppDatabase
e, finalmente, as implementações para IUsersRepository
e ILocationsRepository
. Há um pequeno problema aqui — ILocationsRepository
deve retornar um UserLocation
, mas recebe um UserLocationEntity
do banco de dados. O mesmo é para classes relacionadas ao User
. Na direção oposta, passamos o UserLocation
quando o banco de dados requer UserLocationEntity
. Para resolver isso, precisamos de Mappers
entre nosso domínio e entidades de dados. Usei um dos meus recursos favoritos do Kotlin — extensões. Eu criei um arquivo chamado Mapper.kt
, e coloquei todos os métodos para mapeamento entre as classes lá (claro, está na camada de modelo - o domínio não precisa disso):
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())
A pequena mentira que mencionei antes é sobre entidades de domínio. Eu escrevi que eles não sabem nada sobre o Android, mas isso não é totalmente verdade. Adicionei a anotação @Parcelize
à entidade User
e estendi o Parcelable
para lá, possibilitando passar a entidade para um fragmento. Para estruturas mais complicadas, devemos fornecer as próprias classes de dados da camada de visualização e criar mapeadores como entre domínio e modelos de dados. Adicionar Parcelable
à entidade de domínio é um pequeno risco que me atrevi a correr - estou ciente disso e, no caso de qualquer alteração na entidade de User
, criarei classes de dados separadas para a apresentação e removerei Parcelable
da camada de domínio.
A última coisa a fazer é alterar nosso módulo de injeção de dependência para fornecer a implementação do Repository
recém-criada em vez do MemoryRepository
anterior. Depois de compilar e executar o aplicativo, podemos ir ao PM para mostrar o aplicativo em funcionamento com um banco de dados Room. Também podemos informar ao PM que adicionar uma rede não levará muito tempo e que estamos abertos a qualquer biblioteca de armazenamento que a administração desejar. Você pode verificar quais arquivos foram alterados - apenas os da camada do modelo. Nossa arquitetura é muito legal! Cada próximo tipo de armazenamento pode ser construído da mesma maneira, apenas estendendo nossos repositórios e fornecendo as implementações adequadas. Claro, pode acontecer que precisemos de várias fontes de dados, como um banco de dados e uma rede. O que então? Nada demais, apenas teríamos que criar três implementações de repositório - uma para a rede, uma para o banco de dados e uma principal, onde a fonte de dados correta seria selecionada (por exemplo, se tivermos uma rede, carregue do rede e, se não, carregue de um banco de dados).
Você pode conferir o aplicativo para esta etapa no GitHub com a tag architecture_v2.
Então, o dia está quase terminando - você está sentado em frente ao seu computador com uma xícara de café, o aplicativo está pronto para ser enviado para o Google Play, quando de repente o gerente de projeto vem até você e pergunta "Você poderia adicionar um recurso que pode salvar a localização atual do usuário do GPS?”
Alteração 2
Tudo muda… especialmente o software. É por isso que precisamos de código limpo e arquitetura limpa. No entanto, mesmo as coisas mais limpas podem ficar sujas se estivermos codificando sem pensar. O primeiro pensamento ao implementar a obtenção de uma localização do GPS seria adicionar todo o código de reconhecimento de localização na atividade, executá-lo em nosso SaveLocationDialogFragment
e criar um novo UserLocation
com os dados correspondentes. Este pode ser o caminho mais rápido. Mas e se nosso PM maluco vier até nós e nos pedir para mudar a localização do GPS para algum outro provedor (por exemplo, algo como Bluetooth ou rede)? As mudanças logo sairiam do controle. Como podemos fazê-lo de forma limpa?
A localização do usuário são dados. E obter uma localização é um UseCase
— então acho que nossas camadas de domínio e modelo também devem estar envolvidas aqui. Assim, temos mais um UseCase
para implementar— GetCurrentLocation
. Também precisamos de algo que forneça uma localização para nós - uma interface ILocationProvider
, para tornar o UseCase
independente de detalhes como o sensor 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() } }
Você pode ver que temos um método adicional aqui - cancelar. Isso ocorre porque precisamos de uma maneira de cancelar as atualizações de localização do GPS. Nossa implementação do Provider
, definida na camada do modelo, vai aqui:
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 } }
Este provedor está preparado para trabalhar com corrotinas Kotlin. Se você se lembra, o método run dos UseCase
é chamado em um thread em segundo plano - portanto, temos que nos certificar e marcar adequadamente nossos threads. Como você pode ver, temos que passar uma atividade aqui - é crucialmente importante cancelar atualizações e cancelar o registro dos ouvintes quando não precisarmos mais deles para evitar vazamentos de memória. Como ele implementa o ILocationProvider
, podemos modificá-lo facilmente no futuro para algum outro provedor. Também podemos testar facilmente o manuseio da localização atual (automática ou manualmente), mesmo sem habilitar o GPS em nosso telefone - tudo o que precisamos fazer é substituir a implementação para retornar uma localização construída aleatoriamente. Para fazê-lo funcionar, temos que adicionar o UseCase
recém-criado ao LocationsViewModel
. O ViewModel, por sua vez, precisa ter um novo método, getCurrentLocation
, que na verdade chamará o caso de uso. Com apenas algumas pequenas alterações na interface do usuário para chamá-lo e registrar o GPSProvider no Dagger - e voila, nosso aplicativo está concluído!
Resumo
Eu estava tentando mostrar como podemos desenvolver um aplicativo Android fácil de manter, testar e alterar. Também deve ser fácil de entender — se alguém novo entrar no seu trabalho, não deverá ter problemas para entender o fluxo de dados ou a estrutura. Se eles estiverem cientes de que a arquitetura está limpa, eles podem ter certeza de que as alterações na interface do usuário não afetarão nada no modelo e a adição de um novo recurso não levará mais do que o previsto. Mas este não é o fim da jornada. Mesmo se tivermos um aplicativo bem estruturado, é muito fácil quebrá-lo com mudanças de código confusas “só por um momento, apenas para funcionar”. Lembre-se – não há código “só por enquanto”. Cada código que quebra nossas regras pode persistir na base de código e pode ser uma fonte de futuras quebras maiores. Se você chegar a esse código apenas uma semana depois, parecerá que alguém implementou algumas dependências fortes nesse código e, para resolvê-lo, você terá que vasculhar muitas outras partes do aplicativo. Uma boa arquitetura de código é um desafio não apenas no início do projeto – é um desafio para qualquer parte da vida útil de um aplicativo Android. Pensar e verificar o código deve ser considerado toda vez que algo vai mudar. Para lembrar disso, você pode, por exemplo, imprimir e pendurar seu diagrama de arquitetura do Android. Você também pode forçar um pouco a independência das camadas separando-as em três módulos Gradle, onde o módulo de domínio não está ciente dos outros e os módulos de apresentação e modelo não usam um ao outro. Mas nem isso pode substituir a consciência de que a bagunça no código do aplicativo se vingará de nós quando menos esperamos.