Table of Contents
Una de las tareas más importantes que llevamos a cabo en Apiumhub es colaborar con nuestros clientes para la implantación de metodologías ágiles de desarrollo y la introducción de buenas prácticas de desarrollo de software (arquitectura de software, test….). Y hoy me gustaría hablar sobre MVP puro y enseñaros caso real de aplicación de MVP puro en B-wom.
Trabajando con código legado
Normalmente los proyectos que solemos recibir en Apiumhub contienen gran cantidad de código legado y nuestra tarea es asesorar y ayudar a nuestros clientes en la mejora del código, la implantación de nuevas funcionalidades y la mejora del sistema de empaquetado y distribución.
En el caso concreto de B-Wom teníamos 2 grandes partes del sistema bien diferenciadas, primero la parte de backend que era una gran cantidad de código escrito en PHP y con un acoplamiento muy grande y una segunda parte que eran 2 aplicaciones móviles una para android escrita en Java y otra para iOS escrita en Swift (ambos son los lenguajes nativos de las plataformas).
En la parte de backend nuestra tarea fue más de asesoramiento que la de desarrollo real, ya que la gran parte del refactoring se hizo internamente por parte de B-Wom y nosotros nos implicamos a nivel de empaquetado y asistencia en el montaje de integración continua.
A nivel de aplicaciones móviles nuestra implicación fue bastante más importante porque nos encargamos de la tarea de reescribir el código necesario y también de refactorizar y añadir mejoras para el empaquetado de las aplicaciones.
Normalmente el trabajo con el código legado es bastante delicado porque una de las primeras tareas que tienes que tener en cuenta, es que vas a necesitar modificar código sin alterar el correcto funcionamiento de la aplicación.
En este caso para no incurrir en un problema de ‘Refactoring Nightmare’ y que el proyecto se volviera intratable optamos por intentar ser lo menos intrusivos posible y seguir la técnica de ‘pasos pequeños’ (baby steps), que nos permitió ir haciendo pequeños cambios en el código y evitar en la medida de lo posible romper la funcionalidad.
La forma de trabajar que seguimos (modus operandi) fue la siguiente:
- Primero identificamos las partes del código que correspondían a una determinada funcionalidad y analizabamos cuánto acoplamiento con otras partes del código encontrábamos.
- Una vez identificamos las dependencias de la funcionalidad, aplicamos técnicas de inversión de dependencias para desacoplar el código y dividirlo en funcionalidades más reducidas.
- Cuando detectamos código que se repite lo que hacemos es crear métodos privados que centralizan las llamadas a ese código.
- Si estos métodos son compartidos por otras clases diferentes a la que estamos añadiendo funcionalidad, lo que hacemos es crear clases separadas con un sentido funcional (tipando las clases) para exponer una funcionalidad única y común compartida con otras partes de la aplicación.
- Si era posible se cubría con test la nueva funcionalidad para asegurar su correcto funcionamiento
Arquitectura del código legado
La estructura del código legado de las aplicaciones móviles estaba razonablemente bien separado ya que había una serie de componentes con una tarea específica. Denominamos a esta arquitectura ‘MVC custom’.
Arquitectura en iOS
En la parte de iOS la arquitectura del código legado consistía en un Model – View – Controller con ciertas particularidades. La diferencia esencial con un MVC tradicional radica en la parte del Modelo, concretamente en los ‘Servicios’ que eran implementados por unas clases denominadas ‘Controllers’ que contiene métodos estáticos que llaman a un ApiManager que es un wrapper de ‘Alamofire’ y que se encarga de hacer las llamadas a backend.
Toda la parte del modelo se gestiona a través de ‘Realm’ y se almacena de forma local para poder añadir cierta capacidad ‘reactiva’ a la aplicación.
El resto de la arquitectura se apoya en protocolos y extensión class para desacoplar y proporcionar la funcionalidad necesaria a la aplicación.
Arquitectura en Android
En la parte de android la arquitectura utilizada era muy similar a la parde de iOS en los fundamentos básicos, pero en lugar de utilizar un MVC utilizaron un MVP ligeramente modificado para mejorar el desacoplamiento de las funcionalidades. Siguiendo las bases de la arquitectura propuesta por Google para la creación de MVP. El Presenter se comunicaba con un ‘Controller’ que funciona de una forma prácticamente idéntica al ‘Servicio’ que explicamos en la arquitectura de iOS, que mediante métodos estáticos utiliza un ApiManager que es un wrapper de Retrofit y gestiona las llamadas en backend que se acaban persistiendo en Realm.
Nueva arquitectura: MVP puro
En aquellas funcionalidades donde el desarrollo era completamente nuevo como en el caso de la nueva funcionalidad de Coach, aprovechamos para poder añadir la nueva propuesta de arquitectura de Apiumhub, supervisada por nuestro arquitecto Christian Ciceri – MVP puro.
La idea se basa en poner en práctica un ejemplo de arquitectura que es transversal a la plataforma (porque utilizamos la misma idea en android y en IOS) y que nos permite separar de una forma bastante fácil y clara las responsabilidades de los diferentes componentes de las aplicaciones.
MVP puro es básicamente como ya explicamos en otro articulo un Model – View – Presenter con la particularidad de que el presenter solo hace de conector entre la vista y el servicio.
La arquitectura básica que hemos implementado en B-Wom se basa en Rx, por lo que hemos utilizado RxSwift y RxJava para añadir la parte reactiva de la aplicación. En este articulo vamos a explicar la arquitectura de abajo hacia arriba (bottom – Up) empezando describiendo el conector api que ejecuta las llamadas al backend y terminando en la vista.
Api Manager
Nuestro Api Manager es un wrapper que se apoya en los diferentes frameworks de las 2 plataformas para mediante Rx hacer las llamadas a backend y comunicarse a través de una API Rest. Normalmente es una clase estática que tiene definidos a modo de configuración las llamadas a backend. En el caso de android usamos el wrapper sobre retrofit y en caso de iOS sobre Moya que es un framework que facilita mucho el uso de Alamofire con Rx. Sobre el Api Manager no hemos hecho test unitarios.
Ejemplo iOS:
enum ApiClient {
case getSurveys
}
extension ApiClient:TargetType, AccessTokenAuthorizable {
var baseURL:URL {
return URL(string: API_URL)!
}
var path: String {
switch self {
case .getSurveys:
return "".getApiEndpointPath(type: .kAPI_ENDPOINT_SURVEYS)
}
var method: Moya.Method {
switch self {
case .getSurveys:
return .get
}
var task: Task {
switch self {
case .getSurveys:
return .requestPlain
}
var sampleData: Data {
switch self {
case .getSurveys:
return stubbedResponse("surveyList")
}
func stubbedResponse(_ filename: String) -> Data! {
@objc class TestClass: NSObject { }
let bundle = Bundle(for: TestClass.self)
let path = bundle.path(forResource: filename, ofType: "json")
return try? Data(contentsOf: URL(fileURLWithPath: path!))
}
}
Ejemplo Android:
interface NetworkService {
@GET("$VERSION3$PATH_LANG$SURVEYS")
fun getSurveyHistory(@Path("$LANG_PARAM") lang:String): Observable<Result<BaseResponse<List>>>
companion object Factory {
private val gson: Gson = GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss") .create()
fun create(): NetworkService {
return createWithParams(BuildConfig.HOST)
}
fun createWithParams(baseUrl:String): NetworkService {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(initOkHTTP())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
return retrofit.create(NetworkService::class.java)
}
}
}
El Repository
En MVP puro que diseñamos en Apiumhub el repository es el encargado de conectar con el ApiManager o conectar con el sistema local de almacenamiento (local storage), dependiendo de si la llamada es online u offline. Lo único que hace el repository es exponer los métodos concretos que tendrá la aplicación independientemente del sistema de persistencia, en nuestro caso, tendría los parámetros de entrada y devolvería un observable con el tipo que toque. En el caso del repository se hace un test de integración con el ApiManager para garantizar que estos 2 componentes funcionan de forma correcta y coordinada. Como no contienen lógica de negocio, no consideramos el uso de test unitarios para validar el código ya que creemos que los test de integración cubrimos esos casos de uso.
Ejemplo iOS:
protocol SurveyRepositoryInterface {
func getSurveyList() -> Observable<[String]>
func sendSurvey(surveyId:String, surveyRequest:SurveyRequest) -> Observable
func getSurveyHistory(surveyId:String) -> Observable<[Survey]>
func getAllSurveyHistory() -> Observable<[Survey]>
}
class SurveyRepository: SurveyRepositoryInterface {
let defaultKeyPath = "payload"
private let apiClient:MoyaProvider
init(apiClient:MoyaProvider = ApiClientFactory.createProvider()) {
self.apiClient = apiClient
}
func getSurveyList() -> Observable<[String]> {
return apiClient.rx.request(.getSurveys)
.asObservable()
.filterSuccess()
.map([String].self, atKeyPath: defaultKeyPath)
.asObservable()
}
}
Ejemplo android:
interface SurveyRepository:INetworkBaseRepository {
fun getSurveyHistory(surveyId:String): Observable<List>
companion object {
fun create(): SurveyRepository {
return SurveyRepositoryImpl(NetworkService.create())
}
}
}
class SurveyRepositoryImpl(apiService: NetworkService) : BaseRepository(apiService), SurveyRepository {
override fun getSurveyHistory(surveyId: String): Observable<List> {
return executeRequest(apiService.getSurveyHistory(LocaleHelper.getAppLanguage(), surveyId), listOf())
}
}
El Servicio
El servicio es la clase que se encarga de llamar a los repositorios, ejecutar la lógica de negocio y los cambios de estado necesarios en los datos y devolver el resultado a través de un callback a la vista utilizando para ello el presenter a modo de ‘conector’. En el caso de la funcionalidad que estamos tratando, el servicio tiene muy poca lógica y mayormente los cambios de estado se resuelven mediante Rx. El servicio ejecuta un método que es llamado desde la vista a través del presenter, para ello utiliza el repository para obtener los datos. El repository devuelve un observable y a este observable se han subscrito diferentes publishers dependiendo del tipo de información que se quiera devolver o si ha habido un error o no.
El disponer de diferentes publishers para una misma subscripción nos permite filtrar los datos y tratar los cambios de estado llamando un método diferente de la vista según el estado de los mismos. Por ejemplo si recuperamos la lista de ‘Surveys’ y hay un error el servicio llamara al método onError() y si esta todo correcto llamará al metodo onSuccess(). En los servicios utilizamos test unitarios para checkear la aplicación
Ejemplo iOS:
protocol SurveyServiceInterface {
func getSurveyList()
func onSurveyList(onSuccess: @escaping (([String]) -> Void))
func onErrorSurvey(onError : @escaping (Error) -> ())
}
class SurveyService: SurveyServiceInterface {
private let disposeBag = DisposeBag()
private let repository:SurveyRepositoryInterface
private let surveyListStream = PublishSubject<[String]>()
private let errorStream = PublishSubject<[Error]>()
init(repository:SurveyRepositoryInterface) {
self.repository = repository
}
func getSurveyList() {
repository.getSurveyList().subscribe(onNext: { surveyList in
self.surveyListStream.onNext(surveyList)
}, onError: { error in
self.errorStream.onNext([error])
}).disposed(by: disposeBag)
}
func onSurveyList(onSuccess: @escaping (([String]) -> Void)) {
self.surveyListStream.subscribe(onNext: { surveyList in
onSuccess(surveyList)
}).disposed(by: disposeBag)
}
func onErrorSurvey(onError: @escaping (Error) -> ()) {
self.errorStream.subscribe(onNext: { (errors) in
onError(errors.first!)
}).disposed(by: disposeBag)
}
}
Ejemplo en Android
interface SurveyService:BaseService {
fun getSurveyHistory(surveyId:String)
fun onSurveyHistorySuccess(onSuccess: (surveyList: List) -> Unit)
fun onErrorSurvey(onError:(exception:Throwable) -> Unit)
companion object {
fun create(sequenceNumberProvider: SequenceNumberProvider): SurveyService {
return SurveyServiceImpl(SurveyRepository.create(), sequenceNumberProvider)
}
}
}
class SurveyServiceImpl(private val repository:SurveyRepository): BaseServiceImpl(), SurveyService {
private val surveyHistoryObservable:PublishSubject<List> = PublishSubject.create()
private val errorObservable:PublishSubject = PublishSubject.create()
override fun getSurveyHistory(surveyId: String) {
val demoSurvey:Observable<List> = repository.getSurveyHistory(DEMO_SURVEY_ID)
val screeningSurvey:Observable<List> = repository.getSurveyHistory(SCREENING_SURVEY_ID)
Observables.combineLatest(demoSurvey,screeningSurvey){t1, t2 -> t1 + t2}
.subscribeOn(Schedulers.newThread())
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntil(disposableObservable,{
Log.d("aquitamosServicio", "getSurveyHistory")
surveyHistoryObservable.onNext(it)
}, {errorObservable.onError(it)})
}
override fun onSurveyHistorySuccess(onSuccess: (surveyList: List) -> Unit) {
surveyHistoryObservable
.filter {
isHistorySurveySuccess(it)
}
.subscribeOn(Schedulers.newThread())
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntil(disposableObservable, {
Log.d("aquitamosServicio", "onSurveyHistorySuccess")
onSuccess(it)
})
}
private fun isHistorySurveySuccess(surveyList:List):Boolean {
return !surveyList.isEmpty() && !isNextAction(surveyList.last())
}
override fun onErrorSurvey(onError: (exception: Throwable) -> Unit) {
INetworkBaseRepository.errorsStream
.subscribeOn(Schedulers.newThread())
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntil(disposableObservable, { onError(it) })
}
}
El Presenter
En nuestra arquitectura el presenter es a la vez la pieza más importante y la que nos permite construir aplicaciones más modulares y con un muy bajo acoplamiento porque lo único que hace es conectar los métodos de la vista y el servicio, hace de ‘pasabolas’ y carece completamente de lógica de ningún tipo. Esto lo que nos permite es que sea una pieza de software altamente reutilizable y también que podamos disponer de varios servicios por vista, que aunque esto parezca una contradicción es la ventaja de un sistema modular, ya que lo que sí pretendemos es que el binomio, vista – presenter sea único. Estas piezas de software al carecer de funcionalidad, no les añadimos test.
Ejemplo de iOS:
protocol SurveyViewInterface:class {
func getAllSurveyHistory(action:@escaping () -> ())
func onSurveyHistorySuccess(surveyList:[Survey])
func onSurveyError(error:Error)
}
protocol SurveyServiceInterface {
func getAllSurveyHistory()
func onSurveyHistorySuccess(onSuccess: @escaping(([Survey]) -> Void))
func onErrorSurvey(onError : @escaping (Error) -> ())
}
struct SurveyPresenter:LifeCycleAwareComponent {
var view:SurveyViewInterface
var service:SurveyServiceInterface
func onLifeCycleEvent(event: LifeCycleEvent) {
if (event == .viewDidLoad) {
view.getAllSurveyHistory(action: service.getAllSurveyHistory)
service.onSurveyHistorySuccess(onSuccess: { self.view.onSurveyHistorySuccess(surveyList: $0)})
} else if (event == .viewWillAppear) {
service.onErrorSurvey(onError: {self.view.onSurveyError(error: $0)})
}
}
}
Para poder hacer el mantenimiento de más de un presenter por vista lo que tenemos es una clase base de la vista, que llamamos LifeCycleAware. Esta clase es la que gestiona el ciclo de vida de la vista y se encarga de registrar los presenters como componentes en un publisher que se encargara de manejar los eventos, esto sería un ejemplo de implementación en iOS:
enum LifeCycleEvent {
case viewDidLoad
case viewWillAppear
case viewDidAppear
case viewDidDissapear
}
protocol LifeCycleAwareComponent {
func onLifeCycleEvent(event:LifeCycleEvent)
}
class LifeCycleAwareViewController: UIViewController {
private let lifeCicleOwner:PublishSubject = PublishSubject()
private var subscriptions:[Disposable] = [Disposable]()
let disposeBag:DisposeBag = DisposeBag()
func subscribeToLifeCycle(component:LifeCycleAwareComponent) {
subscriptions.append(lifeCicleOwner.subscribe(onNext: { event in
component.onLifeCycleEvent(event: event)
}
))
}
override func viewDidLoad() {
super.viewDidLoad()
lifeCicleOwner.onNext(LifeCycleEvent.viewDidLoad)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
lifeCicleOwner.onNext(LifeCycleEvent.viewWillAppear)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
lifeCicleOwner.onNext(LifeCycleEvent.viewDidAppear)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
lifeCicleOwner.onNext(LifeCycleEvent.viewDidDissapear)
subscriptions.forEach { $0.dispose() }
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
}
Este sistema de ciclo de vida define los métodos del ciclo de vida de la vista mediante un Enum, habrá tantos como métodos del ciclo de vida emitan eventos que tenemos que gestionar para comunicar una vista con un servicio, es decir, en iOS si queremos que un método de la vista se ejecute en el viewDidLoad creariamos un método que emitirá un evento para ese método del ciclo de vida y todos los presenters suscritos a ese evento recibirán esa respuesta o ejecutarán la llamada, dependiendo de la lógica que se necesite.
La Vista
Es la encarga de que el usuario interactúe con la aplicación y define los métodos de entrada y salida que provocan las acciones de los usuarios. Normalmente nosotros definimos una interfaz (un contrato) que será implementado por la clase correspondiente dependiendo de la plataforma (en iOS normalmente la implementa un ViewController como una extensión del ViewController y en Android la implementa un fragment).
Esto sería como luciría parcialmente un ejemplo de código en iOS
class SurveyViewController: LifeCycleAwareViewController, SurveyViewInterface {
let dataStream:PublishSubject = PublishSubject()
func getAllSurveyHistory(action: @escaping() -> ()) {
dataStream.subscribe(onNext: { (surveyId) in
action()
}, onError: { [weak self] (error) in
self?.showErrorProgress(message: error.localizedDescription)
}).disposed(by: disposeBag)
}
func onSurveyHistorySuccess(surveyList:[Survey]) {
self.hideLoadingProgress()
surveyList.forEach { (survey) in
self.createDataSourceWithQuestion(surveyQuestion: survey)
if let response = survey.textResponseQuestion() {
let testAnswer = SurveyTestStruct(question: survey, text: response, answers:nil, haveAnswer:true, surveyTestType: .kSurveyTestTypeAnswer)
self.dataSource.append(testAnswer)
}
}
self.collectionView.reloadData()
if self.dataSource.count > 0 {
self.collectionView.scrollToItem(at: IndexPath(row: self.dataSource.count-1, section: 0), at: .bottom, animated: false)
}
}
func onSurveyError(error: Error) {
guard let bwomError = error as? BWAPIError, let errorMessage = bwomError.message else {
self.showErrorProgress(message: "initialtest_api_get_error".localized())
return
}
self.showErrorProgress(message: errorMessage)
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2), execute: {
self.loadInitialSurvey()
})
}
}
En Apiumhub sabemos gracias a nuestra experiencia que no existe una solución definitiva y final para resolver un problema de arquitectura y que cada cliente tiene sus particularidades y casos de uso propios, por lo que MVP puro es una primera aproximación que hemos validado que funciona con aplicaciones reales que están en producción y gracias al feedback de nuestros clientes y la experiencia acumulada vamos mejorando y adaptando día a día.
Suscríbete a nuestro newsletter para estar al día de los eventos, nueva arquitectura mobile y MVP puro en concreto!
Si este artículo sobre MVP puro te gustó, te puede interesar:
Tendencias en aplicaciónes móviles
Debugging con Charles Proxy en Android emulator
Integración Continua en iOS usando Fastlane y Jenkins
Cornerjob – iOS objective-C app un caso de exito