One of the most important tasks that we carry out in Apiumhub is to collaborate with our clients for the implementation of agile development methodologies and the introduction of good software development practices ( software architecture, testing, etc.). And today I would like to focus on Pure MVP architecture that we implemented in B-wom app.
Working with legacy code
Many times the projects that we usually get in Apiumhub contain a great amount of legacy code and our primary task is to help our clients with the improvement of the code, the implementation of new functionalities and the improvement of the packaging and distribution system.
In the specific case of B-Wom we had 2 large parts of the system well differentiated, first one was the part of backend, where a lot of code was written in PHP with coupling and second part was – 2 mobile applications: one for Android written in Java and another one for iOS written in Swift (both are the native languages of the platforms).
In the backend part, our main task was to give advice rather than doing development itself, since the great part of the refactoring was done internally by B-Wom and we were involved more in the packaging and continuous integration implementation part.
As for the mobile applications, here we were fully involved in taking care of the refactoring and improvements for the packaging of the applications.
Normally working with the legacy code is quite delicate because one of the first tasks that you have to take into account is that you will need to modify code without breaking the functionalities of the app.
In this case to avoid facing a problem of “Refactoring Nightmare” and having an intractable project, we opted to try to be as unobtrusive as possible and follow the technique of “small steps” (baby steps), which allowed us to make small changes in the code and avoid as much as possible breaking the functionality.
What we did and how (modus operandi):
- First, we identified the parts of the code that corresponded to a certain functionality and we analyzed how much coupling with other parts of the code we found.
- Once we identified the dependencies of the functionality, we applied dependency investment techniques to decouple the code and divide it into smaller functionalities.
- When we detected code that repeats what we did is create private methods that centralize calls to that code.
- If these methods were shared by different classes to which we were adding functionality, what we did is create separate classes with a functional sense (typing the classes) to expose a unique and common functionality shared with other parts of the application.
- If possible, the new functionality was covered with tests to ensure its correct functioning
Legacy code architecture
The structure of the legacy code of mobile applications was reasonably well separated since there was a series of components with a specific task. We call this architecture ‘MVC custom’.
iOS architecture
In the part of iOS architecture, the legacy code consisted of a Model – View – Controller with certain peculiarities. The essential difference with a traditional MVC lies in the part of the Model, specifically in the “Services” that were implemented by classes called “Controllers” that contain static methods that call an ApiManager which is a wrapper of “Alamofire” and which is responsible for making backend calls.
The part of the model is managed through “Realm” and stored locally to add some “reactive” capacity to the application.
The rest of the architecture relies on protocols and class extension to decouple and provide the necessary functionality to the application.
Android architecture
In the android part the architecture used was very similar to the iOS base in the basic fundamentals, but instead of using an MVC, they used a slightly modified MVP to improve the decoupling of the functionalities. Following the bases of the architecture proposed by Google for the creation of MVP, the Presenter communicated with a “Controller” that works in a way almost identical to the “Service”, which we explained in the iOS architecture, using static methods and using an ApiManager which is a Retrofit wrapper and which manages the backend calls that persist in Realm.
New Pure MVP architecture
In those functionalities where we started the development from scratch, as in the case of the new Coach functionality, we took advantage of the opportunity to add the new architectural proposal of Apiumhub, supervised by our architect Christian Ciceri. We implemented Pure MVP ( MVPP) architecture. The idea is putting into practice an example of architecture that is transversal to the platform (because we use the same idea in Android and iOS) and that allows us to separate in a fairly easy and clear way the responsibilities of the different components of the Applications.
The Pure MVP architecture is basically what we explained in another article a Model – View – Presenter, but here we add “Pure” aspect, and with the particularity that the presenter only serves as a connector between the view and the service.
The basic architecture that we have implemented in B-Wom is based on Rx, so we have used RxSwift and RxJava to add the reactive part of the application. In this article, we will explain the bottom-up architecture, starting by describing the api connector that executes calls to the backend and ends in the view.
Api Manager
Our Api Manager is a wrapper that relies on the different frameworks of the 2 platforms for using Rx to make backend calls and communicate through an API Rest. It is usually a static class that has backend calls defined in a configuration. In the case of Android we use the wrapper over retrofit and in case of iOS over Moya, it is a framework that makes the use of Alamofire with Rx a lot easier. As for the Api Manager ,we have not done unit tests.
iOS example:
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!))
}
}
Android example:
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)
}
}
}
The Repository
In the MVPP architecture that we designed in Apiumhub, the repository is in charge of connecting with the ApiManager or connecting with the local storage system, depending on whether the call is online or offline. The only thing that the repository does is expose the concrete methods that the application will have independently of the persistence system, in our case, it would have the input parameters and it would return an observable with the type that it touches. In the case of the repository, an integration test is done with the ApiManager to guarantee that these 2 components work in a correct and coordinated way. As they do not contain business logic, we do not consider the use of unit tests to validate the code since we believe that the integration tests cover those cases of use.
iOS example:
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()
}
}
Android example:
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())
}
}
The service
The service is the class that is in charge of calling the repositories, executing the business logic and the necessary state changes in the data and returning the result through a callback to the view using the presenter as a “connector”. In the case of the functionality that we are dealing with, the service has very little logic and mostly the state changes are solved by using Rx. The service executes a method that is called from the view through the presenter, using the repository to obtain the data. The repository returns an observable and this observable have subscribed different publishers depending on the type of information that you want to return or if there has been an error or not.
Having different publishers for the same subscription allows us to filter the data and deal with changes in status, naming method differently than the view depending on the status of them. For example if we recover the list of ‘Surveys’ and there is an error the service will call the method onError () and if everything is correct it will call the method onSuccess (). In the services we use unit tests to check the application.
iOS example:
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)
}
}
Android example:
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) })
}
}
The Presenter
In our architecture the presenter is the most important piece and the one that allows us to build more modular applications and with a very low coupling because the only thing it does is connect the methods of the view and the service. This allows us to have a highly reusable piece of software and have several services per view, although it seems to be a contradiction, it is the advantage of a modular system, since what we intend to have is unique binomial, view – presenter. And we do not add tests to this pieces of software lacking functionality.
iOS example:
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)})
}
}
}
To be able to maintain more than one presenter per view, what we have is a base class of view, which we call LifeCycleAware. This class is the one that manages the life cycle of the view and is responsible for registering the presenters as components in a publisher that will be responsible for managing the events. This is the example of implementation it in 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.
}
}
This life cycle system defines the methods of the life cycle of the view through an Enum. There will be as many life cycle methods as needed to emit events that we have to manage to communicate a view with a service. In iOS if we want a method of the view to be executed in the viewDidLoad, we would create a method that will emit an event for that method of the life cycle and all the presenters subscribed to that event will receive that answer or execute the call, depending on the logic that is needed.
The View
It is responsible for the user to interact with the application and defines the methods of input and output that cause the actions of users. Normally we define an interface (a contract) that will be implemented by the corresponding class depending on the platform (in iOS it is usually implemented by a ViewController as an extension of the ViewController and in Android it is implemented in a fragment).
This would be like part of an iOS example:
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()
})
}
}
In Apiumhub we know from our experience that there is not a definitive and final solution to solve an architectural problem and that each client has its own particularities and use cases, so the Pure MVP architecture is a first approach that we have validated that works with real applications that are in production and thanks to the feedback of our customers and the accumulated experience, we are improving and adapting day by day.
Don’t forget to subscribe to our monthly newsletter to receive the latest news about mobile architecture and Pure MVP in particular!
And if you liked this case study about Pure MVP in iOS , you might like…
Banco Falabella wearable for iOS & Android
Lazada Case study: scaling agile
Dexma Case study: App development externalization
iOS objective -C app: Cornerjob successful case study
The post B-Wom case study: Pure MVP application appeared first on Apiumhub.