Marco Ramírez
Posted on February 24, 2021
Hey, ¡mis chamacos! Ya estoy de regreso deleitándolos con más contenido en español. En esta ocasión quiero contarles sobre un poquito más de desarrollo Android. Como ustedes sabrán, uno de los ejercicios que como ingenieros de software nosotros hacemos para pulir nuestras técnicas en el desarrollo de software es el desarrollo del popular POKEDEX, el cual es la guía de todo maestro Pokémon y prácticamente su mano derecha.
¿Qué rayos es el Pokedex?
Como ya lo mencioné, es la guía de un maestro Pokémon en forma de un dispositivo electrónico de formas diferentes, mismo que servía además de "enciclopedia" Pokémon, para otras cosas como mapa y comunicaciones.
¿Qué fue lo que se implementó?
Pues bien, dentro de este desarrollo que se hizo con Kotlin 100%, se estuvieron utilizando diversas prácticas usadas hoy en día en el desarrollo móvil como la implementación de la conocida "Clean Architecture" y de la inyección de dependencias.
Para ello, usamos Koin para la inyección de dependencias y fuimos armando la parte de datos en un módulo "core" separado de la parte de presentación de la app. En la parte de llamadas a la API también se utilizó un poco de RxJava/RxKotlin y RxAndroid.
Luego para el backend, se implementó PokeAPI como backend, mismo que se ha convertido en un referente en el desarrollo de este proyecto en diferentes lenguajes, sobre todo en los que son usados en frontend (JS, Dart, TypeScript) y apps móviles (Swift, Kotlin, Java).
Finalmente para las librerías externas, se utilizaron las habituales en un desarrollo de este tipo tales como:
- Glide
- Retrofit
- GSON (extensión de Retrofit)
Sobre Clean Architecture
Clean Architecture es un marco de trabajo propuesto por Robert C. Martin como forma de estructurar nuestro código en capas, mismas que solamente se pueden comunicar entre las que están a sus lados.
El esquema de Clean Architecture es el siguiente:
Y las capas, tomando en cuenta Android son las siguientes:
UI: La interfaz de usuario (el Fragment). Pero ¡AGUAS!, no quiere decir que vayas a meter toda la lógica de llamadas a datos en el Fragment (mala praxis), para ello se usa el ViewModel del mismo, que es donde viene la siguiente capa.
Presentación: Son clases que se comunican con las clases e interfaces de interacción de nuestra aplicación realizando las llamadas pertinentes a datos. Como lo mencioné en el punto anterior, aquí es donde entran los ViewModel.
Use Cases o Interactors: Estas clases se encargan de interactuar con el modelo de negocio de datos e implementa el modelo de negocio pertinente para el manejo de datos dentro de nuestra aplicación. En este punto es donde uno como desarrollador puede armar diversos comportamientos avanzados con la información entregada por la capa contigua: los Repositorios.
Repositorios o Entities: Aquí es donde se albergan todas las llamadas a datos internos y externos de nuestra aplicación, es decir, las llamadas a REST API, servicios web diversos, bases de datos SQLite (si es que se requiriese ROOM), etc.
Caso Práctico
Ahora bien, como un caso práctico les mostraré un flujo de datos usando CA. Una disculpa si no doy tanto detalle de la implementación y no toco un par de tópicos, pero me gustaría profundizar en ellos por separado:
- ReactiveX (tal vez me aviente algo sobre corrutinas).
- Inyección de Dependencias.
- El modelo MVVM utilizado en apps móviles.
Para fines de implementación se debe de realizar de forma inversa (de adentro hacia afuera) por lo que primeramente sería implementar nuestro llamado a Retrofit de esta forma:
CAPA 4: REPOSITORIOS
interface RestApi {
@GET("region")
fun getRegions(): Observable<RegionResult>
@GET("region/{region}")
fun getDetailedRegion(@Path("region") region: String): Observable<RegionDetailedResult>
@GET("pokedex/{pokedex}")
fun getPokedex(@Path("pokedex") pokedex: String): Observable<PokedexResult>
@GET("pokemon/{pokemon}")
fun getPokemonByName(@Path("pokemon") pokemon: String): Observable<PokemonResult>
@GET("pokemon-species/{pokemon}")
fun getPokemonSpeciesByName(@Path("pokemon") pokemon: String): Observable<PokemonSpeciesResult>
}
Cada uno de los métodos declarados en dicha interfaz serán los utilizados por nuestros repositorios y de esta forma es como los iremos implementando cada uno. Posteriormente, se realiza la implementación de dicha interfaz por medio de una clase:
class RestApiImp {
companion object{
var retrofit: Retrofit? = null
val REQUEST_TIMEOUT: Long = 30
var okHttpClient: OkHttpClient? = null
fun getClient(): Retrofit {
if(okHttpClient == null)
initOkHttpClient()
if(retrofit == null) {
retrofit = Retrofit.Builder()
.baseUrl(Constant.MAIN_URL)
.client(okHttpClient!!)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.build()
}
return retrofit!!
}
private fun initOkHttpClient() {
var httpClient: OkHttpClient.Builder = OkHttpClient().newBuilder()
.connectTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS)
.writeTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS)
var interceptor: HttpLoggingInterceptor = HttpLoggingInterceptor()
interceptor.level = HttpLoggingInterceptor.Level.BODY
httpClient.addInterceptor(interceptor)
httpClient.addInterceptor { chain ->
var original = chain.request()
val newRequest = chain.request().newBuilder()
.addHeader("Accept", "application/json")
.build()
chain.proceed(newRequest)
}
okHttpClient = httpClient.build()
}
}
}
Como pueden ver, la clase cuenta con un "companion object", el cual es el símil en Kotlin de declarar una clase estática y en dicho objeto, tenemos el método getClient, mismo que se encargará de realizar la implementación de Retrofit, la extensión para GSON y un interceptor para inyectar cabeceras antes de llamar al request (es aquí donde podríamos inyectar tokens, sin embargo no las necesitamos en esta ocasión).
Luego, pasamos a los repositorios persé. Para poder llamar a los métodos para obtener los diferentes tipos de "Pokedex", declaramos la siguiente interfaz:
interface PokedexRepository {
fun getPokedex(pokedex: String): Observable<PokedexResult>
}
Con su respectiva implementación:
class PokedexRepositoryImp: PokedexRepository {
private var apiRequest: RestApi = RestApiImp.getClient().create(RestApi::class.java)
override fun getPokedex(pokedex: String): Observable<PokedexResult> {
return apiRequest.getPokedex(pokedex)
}
}
En esta interfaz y clase, podemos nosotros ver que invocaremos como tal a Retrofit hacia un endpoint de los declarados en RestApi.kt pero como parte de la Clean Architecture, quien se encargará de ello serán los Interactors.
CAPA 3: INTERACTORS
Para los Interactors se declarará de la misma manera que los Repositorios, una interfaz y una clase de implementación, quedando de la siguiente forma la interfaz:
interface PokedexInteractor {
fun getPokedex(pokedex: String): Observable<PokedexResult>
}
Y la clase que implementa:
class PokedexInteractorImp(private val pokedexRepositoryImp: PokedexRepositoryImp,
private val pokemonRepositoryImp: PokemonRepositoryImp): PokedexInteractor {
val logger: Logger = LoggerFactory.getLogger(PokedexInteractorImp::class.java.simpleName)
override fun getPokedex(pokedex: String): Observable<PokedexResult> {
return pokedexRepositoryImp.getPokedex(pokedex)
.doOnNext { response ->
run {
for(item: Pokemon in response.pokemonEntries) {
var pokemonID: String = item.pokemonSpecies.url
.replace(Constant.MAIN_URL, "")
.replace("pokemon-species", "")
.replace("/", "")
item.pokemonImage = Constant.POKEMON_IMAGE_URL + pokemonID + ".png"
pokemonRepositoryImp.getPokemon(item.pokemonSpecies.name)
.subscribeOn(Schedulers.io())
.subscribe({response2 -> item.pokemonDetails = response2}, {t -> logger.error(t.message)})
}
}
}
.doOnComplete { logger.debug("Service complete") }
.onErrorReturn { error ->
logger.error(error.message)
null
}
}
}
Y como podemos observar en PokedexInteractorImp.kt, quien se encarga de armar lógicas complejas en el modelo de negocio de la aplicación es el interactor o use case. Es en esta capa donde se recomienda hacer las manipulaciones de datos pertinentes para que en la capa 2, únicamente se encargue de hacer las llamadas pertinentes para obtener estos datos.
CAPA 2: PRESENTACIÓN
En Android, la capa de presentación se ve representada por el ViewModel de nuestro fragmento en cuestión. Por lo tanto, dicho ViewModel tiene que estar establecido y asignado dentro del fragmento. Armamos el ViewModel como sigue:
class PokedexInfoViewModel : ViewModel() {
var compositeDisposable = CompositeDisposable()
lateinit var dependencies: ApiDependencies // I'll talk about this later
var pokemon: MutableLiveData<MutableList<Pokemon>> = MutableLiveData()
fun getPokedex(pokedex: String) {
compositeDisposable.add(dependencies.getPokedex(pokedex)
.subscribeOn(Schedulers.io())
.subscribe(
{res -> pokemon.postValue(res.pokemonEntries) },
{t: Throwable -> Log.e(ContentValues.TAG, t.message!!) }
)
)
}
}
Con esto, nosotros vamos armando la capa 2 y dejamos todo dispuesto a que la capa 1 únicamente quede a la escucha de la información que llegue al momento de requerirlas y seguir el flujo de ida y vuelta conforme a la Clean Architecture.
CAPA 1: UI
Finalmente, el encargado de invocar todo este caminito será el Fragment (puedes usar Activities, pero gastarás más memoria), por lo que tenemos que realizarlo de la siguiente manera:
class PokedexInfoFragment : Fragment() {
private lateinit var viewModel: PokedexInfoViewModel
private lateinit var toolbar: Toolbar
private val dependencies: ApiDependencies by inject()
lateinit var items: MutableList<Pokemon>
lateinit var recyclerView: RecyclerView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.pokedex_info_fragment, container, false)
val activity = requireActivity() as AppCompatActivity
toolbar = view.findViewById(R.id.tb_pokedex_info)
activity.setSupportActionBar(toolbar)
activity.supportActionBar!!.title = arguments?.getString("pokedex")!!
.capitalize(Locale.getDefault()).replace("-", " ") + "'s Pokedex"
activity.supportActionBar!!.setDisplayHomeAsUpEnabled(true)
return view
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
var bundle = bundleOf("region" to arguments?.getString("region"))
Navigation.findNavController(requireView()).navigate(R.id.action_pokedexInfoFragment_to_regionFragment, bundle)
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProvider(this).get(PokedexInfoViewModel::class.java)
viewModel.dependencies = dependencies
viewModel.pokemon.observe(viewLifecycleOwner, Observer {
items = it
initRecyclerView()
})
viewModel.getPokedex(arguments?.getString("pokedex")!!)
}
private fun initRecyclerView() {
val listener = View.OnClickListener() {
val bundle = bundleOf("pokemon" to it
.findViewById<TextView>(R.id.txt_pokemon_name)
.text.toString().toLowerCase(Locale.getDefault()),
"pokedex" to arguments?.getString("pokedex"))
Navigation.findNavController(requireView())
.navigate(R.id.action_pokedexInfoFragment_to_pokemonFragment, bundle)
}
recyclerView = requireView().findViewById(R.id.rv_pokedex_info)
recyclerView.setHasFixedSize(true)
recyclerView.layoutManager = GridLayoutManager(requireContext(), 3)
recyclerView.adapter = PokedexInfoAdapter(items, listener)
}
override fun onDetach() {
viewModel.compositeDisposable.dispose()
items.clear()
super.onDetach()
}
override fun onDestroy() {
viewModel.compositeDisposable.dispose()
items.clear()
super.onDestroy()
}
}
Varios métodos son asignados como override pues son parte de la API de Android, sin embargo son de vital importancia (onDetach y onDestroy) para que el ViewModel no se quede en memoria al momento de cambiar de Fragment, salir de la app o algún punto en el que el ciclo de vida de la app se vea terminado o interrumpido.
El método onActivityCreated es el encargado de realizar el llamado de todo el camino por medio del siguiente bloque:
viewModel.pokemon.observe(viewLifecycleOwner, Observer {
items = it
initRecyclerView()
})
viewModel.getPokedex(arguments?.getString("pokedex")!!)
Al momento de asignar el ViewModel, nosotros asignamos un observer de LifeCycle (no confundir con los observers de ReactiveX) para que en cuanto la información llegue, realice los procesos que uno asigne dentro del Observer y así poder enviar la información hacia la vista del Fragment (nuestro XML).
Siguientes pasos
Hasta ahorita ando viendo el trabajar lo siguiente ya que se tiene una versión estable:
- Mejorar el UX/UI de la app. Ahorita lo hice muy básico.
- Agregar un news feed en el dashboard. Ahorita no lo agregué porque no encontré ninguno libre.
- En lugar de usar RxKotlin, poder cambiar a corrutinas para la parte asíncrona y tener una alternativa de trabajar las llamadas de este tipo. Aunque la verdad, soy más fan de ReactiveX que de las corrutinas, pero uno nunca sabe cuándo necesitará trabajar con ellas.
- Revisar que no haya memory leaks en la app.
- CI/CD
Como es costumbre, he de comentar que no soy el mejor desarrollador de Android y que tal vez este código no sea el mejor que hayas visto y me gustaría mejorarlo. Si te interesa dejar tus issues, darle stars, forkearlo o ayudarme a mejorar, puedes hacerlo en el siguiente repositorio: https://github.com/RZEROSTERN/roadtocertification_pokedex.
Igualmente, si quieren dejar sus comentarios, pueden hacerlo bajo este post. Estaré feliz de contestarlos.
Bueno pues, espero que les haya gustado este post, aunque largo, nutritivo en cuestiones que tal vez puedan sacar de dudas a más de una persona.
Happy coding !!!
Posted on February 24, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.