DEV Community

Angel G. Olloqui for Playtomic

Posted on

Playtomic's Shared Architecture using Swift and Kotlin

Choosing the technology stack is one of the first and most important decisions when starting any project from scratch. At Playtomic, we knew that we wanted to pick a stack for the mobile apps that would allow us to deliver the best possible experience given our limited resources available.

Mobile stacks range over a plethora of alternatives:

Web technologies

Solutions like responsive web apps or progressive web apps allow you to leverage your web frontend experience while having a single project for all. Some native capabilities can not be used but for most apps it would be enough. Distribution is through web browsers and not inside an App Store which may be an advantage or disadvantage depending on your case

Hybrid

Next step on the road to native you can choose to use an hybrid framework like Phonegap/Cordoba, which also uses web technologies but get wrapped into an app bundle, offering extra capabilities and an improved UX over pure web.

Multiplatform Solutions

There are several multiplatform solutions like Xamarin, ReactNative or the newer Flutter. They all have their own selling points and disadvantages, but in general they offer a unified solution to build native apps by using a single language and, to some degree, a single UI layer with custom components, sharing most of the code across the platforms while delivering a good UX, very close (if not the same) than the one delivered by native apps.

Native

The industry standard, and the one that brings the best UX is also the one that requires the most resources. Building native means building an app per platform, each one with its own language, set of tools, libraries and frameworks.

Our dilemma

Without getting into too much detail about each one and our analysis, we knew that we wanted Playtomic to be a leader in the sports area, and being a mobile first project we wanted it to bring the best possible experience to our end users.

We also knew that once picked, that technology stack would be our one and only one stack for long since we do not have the resource power to maintain a “Frankenstein app” built in parts with different stacks or Big Bang refactors to completely migrate from one to another. We wanted “the stack” to be production ready and with enough community and maturity to have the “certainty” that it will be supported for our project’s life.

That basically left us with 3 candidates: Xamarin, ReactNative and Native; and from those first two we were much more appealed by React than Xamarin because of its programming model, the possibilities to share code with a future web frontend and the amazing community and tooling.

On the other hand, when selecting a solution, you also have to consider the team you have. In our case, we were expert native developers, with experience in both platforms (Android and iOS) and with little to no experience in React or other multiplatform stacks. Besides, at that moment, there was no web frontend developer or anyone within the company with enough React experience to coach the mobile team during the learning curve.

Having all that in mind, our best fit was native. It delivers almost everything we wanted (best UX, industry standard, maturity, long term vision, great tools, great community,...) except for one important aspect: cost

As explained before, going native would mean to build 2 apps (iOS/Android), which has an extra cost compared to the single app of multiplatform solutions. But how much? Let’s try to put very rough numbers with an example (note that these are not real numbers, they are just an estimate based on our own previous experience, don’t take them too seriously but just as an illustration):

  • Native: Let’s say you are building a feature that takes you 100h on iOS. Then, porting it to Android would take around 80h extra (not 100 because there is always knowledge that can be “ported” from the first platform). A total of 180h

  • Multiplatform: The same feature would take you around 120h, depending on how much you can reuse and the technology used. It is not write once run everywhere but close enough to add only a small percentage of extra work over a single platform.

So, roughly 180h vs 120h, or in other words around 50% extra time to go native. That is quite a lot! Especially for a small team like ours.

So, our next question was: can we find a way of building a native app maximizing reusability across platforms and keeping the cost down, close to the one delivered by multiplatform solutions? And if so, will it take us less than 1-2 months work of setup? (That was the time we had until the product and design teams would start delivering well defined features to build)

I had participated in the past of some very small projects (Proof Of Concepts and a minor library) using this approach with excellent results. But building a full app is a completely different challenge, especially when the application grows.

Shared foundations

So, we started building with one objective in mind: reusability across native platforms

What we did was to split the app in 3 main parts for both platforms:

Application architecture

  • Anemone SDK: framework used to connect to our backend and provide persistence. It provides Model, Service and some utilities.
  • Playtomic UI: framework with visual components like custom textfields, input validators, generic datasources/adapters, animations, ...
  • Application code: where our modules and features are built. It includes and uses the former two.

We made sure that both frameworks offered the same public API (different implementations) and we also built a few facades over concepts that would be used across the app and that were provided differently on each platform, keeping the same API again. To name a few:

(You can check all code in these repos: Swift Kotlin)

We also picked the combination of Swift/Kotlin because of their enormous similarities and we used SwiftKotlin to minimize the time needed to transpile code from iOS to Android.

Finally, we added a few extensions over foundation objects to provide some of the missing methods on one or the other language (ex: compactMap, flatMap, let,...)

Examples

Let me go quickly through an example, in this case a Login view and presenter in Swift and Kotlin

Transpiling presenters

See in the above screenshot how with just a minor fix on the Kotlin code we get a fully working Android version of a Presenter by transpiling the iOS one in about 15 seconds.

LoginPresenter

Swift

class LoginPresenter: Presenter<ILoginView> {
    let coordinator: IAuthCoordinator
    let appEventManager: IAppEventManager
    let messageBarManager: IMessageBarManager
    let navigationManager: INavigationManager
    let authenticationService: IAuthenticationService    

    init(coordinator: IAuthCoordinator,
         appEventManager: IAppEventManager,
         messageBarManager: IMessageBarManager,
         navigationManager: INavigationManager,
         authenticationService: IAuthenticationService) {
        self.coordinator = coordinator
        self.appEventManager = appEventManager
        self.messageBarManager = messageBarManager
        self.navigationManager = navigationManager
        self.authenticationService = authenticationService
    }

    override func viewPresented() {
        super.viewPresented()
        view?.setIsLoading(false)
        if authenticationService.isLoggedIn() {
            skipLogin()
        }
    }

    func skipLogin() {
        self.view.let { self.navigationManager.dismiss(view: $0, animated: true) }
    }

    func login(email: String, password: String) {
        view?.setIsLoading(true)
        authenticationService.login(user: email, password: password).then { [weak self] _ in
            guard let `self` = self else { return }
            self.view.let { self.navigationManager.dismiss(view: $0, animated: true) }
            self.appEventManager.sendEvent(AppEvent.loginWithCredentials(success: true))
        }.always { [weak self] in
            self?.view?.setIsLoading(false)
        }.catchError { [weak self] (error) in
            self?.messageBarManager.showError(error: error)
            self?.appEventManager.sendEvent(AppEvent.loginWithCredentials(success: false))
        }
    }

    func rememberPassword() {
        navigationManager.show(coordinator.requestPasswordIntent(), animation: NavigationAnimation.push)
    }

}
Enter fullscreen mode Exit fullscreen mode

Kotlin

class LoginPresenter(
        private val coordinator: IAuthCoordinator,
        private val appEventManager: IAppEventManager,
        private val messageBarManager: IMessageBarManager,
        private val navigationManager: INavigationManager,
        private val authenticationService: IAuthenticationService)
    : Presenter<ILoginView>() {

    override fun viewPresented() {
        super.viewPresented()
        view?.setIsLoading(false)
        if (authenticationService.isLoggedIn()) {
            skipLogin()
        }
    }

    fun skipLogin() {
        this.view?.let { this.navigationManager.dismiss(view = it, animated = true) }
    }

    fun login(email: String, password: String) {
        view?.setIsLoading(true)
        authenticationService.login(email, password)
                .then {
                    this.view?.let { this.navigationManager.dismiss(view = it, animated = true) }
                    appEventManager.sendEvent(AppEvent.loginWithCredentials(success = true))
                }
                .always { 
                    view?.setIsLoading(false) 
                }
                .catchError { error ->
                    messageBarManager.showError(error = error)
                    appEventManager.sendEvent(AppEvent.loginWithCredentials(success = false))
                }
    }

    fun rememberPassword() {
        navigationManager.show(coordinator.requestPasswordIntent(), animation = NavigationAnimation.push)
    }

}
Enter fullscreen mode Exit fullscreen mode
String similarity: 73.59%

LoginView

class LoginViewController: PresenterViewController<LoginPresenter> {
    @IBOutlet weak var usernameTextField: PlaytomicTextField!
    @IBOutlet weak var passwordTextField: PlaytomicTextField!
    @IBOutlet weak var loginButton: UIButton!
    @IBOutlet weak var dismissButton: UIButton!
    @IBOutlet weak var loadingIndicator: UIActivityIndicatorView!

    override func viewDidLoad() {
        super.viewDidLoad()

        usernameTextField.configure(
            inputType: .email,
            labelText: R.string.localizable.auth_login_user_field(),
            errorMessage: R.string.localizable.auth_login_user_error(),
            validators: [
                TextFieldEmailValidatorBehavior(textField: usernameTextField.textField)
            ],
            editTextDidChangeCallback: { [weak self] in self?.reloadLoginButtonState() }
        )

        passwordTextField.configure(
            inputType: .password,
            labelText: R.string.localizable.auth_login_password_field(),
            errorMessage: R.string.localizable.auth_login_password_error(),
            validators: [
                TextFieldLengthValidatorBehavior(textField: passwordTextField.textField, minLength: 5, maxLength: nil)
            ],
            editTextDidChangeCallback: { [weak self] in self?.reloadLoginButtonState() }
        )
        reloadLoginButtonState()
    }

    @IBAction func login() {
        view.endEditing(true)
        presenter.login(email: usernameTextField.text, password: passwordTextField.text)
    }

    @IBAction func skipLogin() {
        presenter.skipLogin()
    }

    @IBAction func rememberPassword() {
        presenter.rememberPassword()
    }

    func reloadLoginButtonState() {
        let fieldsValid = usernameTextField.isValid && passwordTextField.isValid
        let loading = loadingIndicator.isAnimating
        loginButton.isEnabled = fieldsValid && !loading
    }

    // ****  View Interface  ****

    func setIsLoading(loading: Bool) {
        if newValue {
            loadingIndicator.startAnimating()
        } else {
            loadingIndicator.stopAnimating()
        }
        reloadLoginButtonState()
    }

}
Enter fullscreen mode Exit fullscreen mode
class LoginFragment : PresenterFragment<LoginPresenter>(R.layout.login_fragment), ILoginView {

    @BindView(R.id.username_edit_text_custom)
    lateinit var usernameCustomEditText: PlaytomicTextField

    @BindView(R.id.password_edit_text_custom)
    lateinit var passwordCustomEditText: PlaytomicTextField

    @BindView(R.id.login_button)
    lateinit var loginButton: Button

    @BindView(R.id.toolbar_back_button)
    lateinit var dismissButton: ImageButton

    @BindView(R.id.loading_indicator)
    lateinit var loadingIndicator: ProgressBar

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        usernameCustomEditText.configure(
                inputType = PlaytomicTextField.InputType.email,
                labelText = R.string.auth_login_user_field,
                errorMessage = R.string.auth_login_user_error,
                validators = listOf(
                        TextFieldEmailValidatorBehavior(usernameCustomEditText.editText)
                ),
                editTextDidChangeCallback = ::reloadLoginButtonState
        )
        usernameCustomEditText.nextTextField = passwordCustomEditText

        passwordCustomEditText.configure(
                inputType = PlaytomicTextField.InputType.password,
                labelText = R.string.auth_login_password_field,
                errorMessage = R.string.auth_login_password_error,
                validators = listOf(
                        TextFieldLengthValidatorBehavior(passwordCustomEditText.editText, 5, null)
                ),
                editTextDidChangeCallback = ::reloadLoginButtonState
        )
        reloadLoginButtonState()
    }

    @OnClick(R.id.login_button)
    internal fun login() {
        hideKeyboard()
        presenter.login(email = usernameCustomEditText.text, password = passwordCustomEditText.text)
    }

    @OnClick(R.id.auth_login_forget_password_button)
    internal fun rememberPassword() {
        presenter.rememberPassword()
    }

    private fun reloadLoginButtonState() {
        val fieldsValid = usernameCustomEditText.isValid && passwordCustomEditText.isValid
        val loading = loadingIndicator.visibility == View.VISIBLE
        loginButton.isEnabled = fieldsValid && !loading
    }

    // ****  View Interface  ****

    override fun setIsLoading(loading: Boolean) {
        loadingIndicator.visibility = if (loading) View.VISIBLE else View.GONE
        reloadLoginButtonState()
    }

}
Enter fullscreen mode Exit fullscreen mode
String similarity: 70.8%

As you can see, code is basically the same except for some language differences (constructors, keywords,...) that can be quickly transpiled. Moreover, by using PlaytomicUI components and some of our extensions, code is similar even on the View layer. The main work on this part corresponds to laying out elements in Interface Builder (iOS) or in layout XMLs (Android).

An interesting note to make here is that we could  have decided to write the Views in code or with tools like Layout. That would make possible to reuse much more here as well, but we intentionally chose not to because we wanted to keep this part (the one the user actually sees and experiences) as standard as possible. This also allows us to use and follow platform components and conventions when desired and the de facto developer tools available, hence keeping a moderate learning curve and taking advantage to a full extent of our native development expertise.

The good, the bad and the ugly

After 1.5 years working with the explained Shared Architecture, conventions and tools, we have a pretty solid view of what’s working for us and what is not working that well. Let me try to make an introspection:

The good

  • Team unification: there is no Android/iOS team distinction because the same developer always transpiles his work to the other platform. This results in extreme union, platform parity and less disputes/blockages
  • Team performance: developing app code is much faster than writing 2 platforms independently. It typically takes just a few minutes to transpile Presenters, Interactors, Coordinators, Models and Services. XML and Xib files takes the rest of the time, and every now and then some code in managers. In average, we take about 30% extra time to convert from one to the other platform, depending on the amount and complexity of the views involved, pretty close to multiplatform solutions.
  • Fully native UX: Visual components and app performance is the same than any other native app. Besides, there is no extra penalty on app size nor app launch time like in multiplatform solutions.
  • Long term vision: we use the facto tools, frameworks and languages on each platform, and we have no important dependencies. We can have the certainty that code will be valid for many years, even if at some point team grows and we stop sharing code they will still be valid standard native projects independently.
  • Good abstractions and code quality: The fact that we want to reuse as much code as possible forces developers to think very carefully the abstractions they want to build. It encourages for proper separation of concerns, single responsibility classes, more testing (to verify the transpilation), etc. In fact I would even say that code reviews are also more effective as you can compare the PR side by side with the counterpart and detect issues on a higher level. Quality is not just desirable but it is also perceived as an actual productivity boost from day 1.
  • Reduced project’s cognitive load: Having 1 code base makes understanding the project and remembering the internal details much easier.

The bad

  • Extra architecture work: it is no secret that building these shared abstractions and extensions take time. In our case we dedicated about 1 month to architectural foundations, and since then we have had to make some changes and additions every now and then. The total overhead is difficult to calculate, but it is noticeable especially at the beginning.
  • Hidden bugs from language differences: transpilation works great, most of the time 💥. However, during these 18 months working on it, we have encountered 3 or 4 times bugs derived from language differences that were unexpected. Especially important is the Value type (struct) in Swift that has no counterpart in Kotlin or the sort in place of arrays in Kotlin. These differences impose restrictions and are a source of bugs if not considered properly.
  • Maximum common factor of language features: in parallel to the previous bullet, having to share code imposes restrictions on the way you use a language (or more transpilation work is required). As a result, we tend to write code using the maximum common factor of language features. A few examples of limitations on Swift are value types and protocol extensions, while in Kotlin the usage of decorators or default parameters in interfaces.
  • View layer needs work per platform: writing views require specific work per platform. That has an impact on development time, testing and bug fixing that would not ocurr that much with multiplatform solutions with shared UI.
  • Learning curve: all the architectural components and code conventions that we use are specific from this project and therefore require some learning. Nevertheless, to be fair all projects have their own internal conventions and architecture design, so at least having the same across both platforms means that there is only 1 curve to pass and not 2.

The ugly

  • Hybrid conventions: Kotlin and Swift are very similar but they use different naming conventions. For example, in Kotlin/Java constants are written in upper case while in Swift they aren’t. Or the classical I prefix so common in Java does not exist in Swift (the closest would be to suffix Protocol to the name). As a result, when sharing code you have to either come with a mix of conventions or penalize the transpilation process with more manual edition to adapt from one to the other. We started with conventions per platform and we are gradually moving into a single convention that feels to us like the best of both worlds and which is becoming our de facto mobile team convention (but it would look “ugly” to external developers)
  • Replicate changes manually: transpilation works great when building new features because you can copy&paste the full transpiled code. However, when maintaining code, it is up to the developer to remember to apply the same change made on platform A into platform B. As a result, we have sometimes forgotten to replicate, resulting in some inconsistencies on app behavior. We are controlling that through PR, forcing both platforms to have the same kind of changes and reviewing them in parallel, but there is still the case for human error.
  • Team scaling: having such a small team helps when using this approach since it requires lots of communication between members. We are not sure how this would scale with more developers, but we suspect it won’t the day we have 6+ people or so. Besides, we are “forced” to hire (or teach) multiplatform experts as long as we want to keep sharing code efficiently.

Overall, when looking back, we feel the decision has been the correct one for our team. That does not mean that using React would have been a mistake (probably not), but we are very satisfied with the results we are getting. Sure, we run into some issues every now and then, and we have had to invest a couple of months on making the abstractions (this time would have gone to learning React anyway), but we have now the best UX possible with a very decent development speed. Moreover, we are not dependent on some third party framework or tool (even SwiftKotlin could be removed and just transpile code manually, which is not that bad anyway) what gives us long term confidence, and we are free to chose the application architecture we prefer per module (MVP, MVVM, VIPER, REDUX,...). We can also leverage all of the native goodies the instant they are announced and we can use the team knowledge to full extent.

String similarity calculated with Tools4Noobs

Oldest comments (5)

Collapse
 
rolgalan profile image
Roldán Galán

The goal is so nice, but converting Swift code into separate independent Kotlin code (or the opposite) manually (helped by the tool) doesn' scale. For example, fixing a bug in one platform requires fixing as well in the other...

Have you considered to create common shared Kotlin libraries encapsulating the business logic? I think this is the definitive solution for mobile development. It requires a clean and nice architecture in both platforms, but I'm pretty sure this will eventually become the standard.

blog.kotlin-academy.com/multiplatf...

Regards!

Collapse
 
angelolloqui profile image
Angel G. Olloqui

Hi Roldán, having to keep two separate projects with equivalent source code is indeed something that will probably not scale well (I actually mentioned it in the article). However, for small teams is not that big of a problem.

Using a unique code base or shared library written in single language and compiled for both is something that comes with its own issues as well, so it is all about deciding which of the penalties you prefer to pay. In this sense, kotlin is doing a great movement with kotlin native, but it is still too young (IMO) to be consider for production. Besides, you will need to use specific tools that will not be part of the standard and you will be bound to that choice for longer. It is definetly something very interesting, but I think is still too early (for kotlin) or too cumbersome if you go with C++, JavaScript or alike. Anyway, I would love to hear your experience if you have use that approach in a real project

Collapse
 
piannaf profile image
Justin Mancinelli • Edited

Hi Angel, very cool what you are working on!

Kotlin Native has been maturing quickly. And the company I work for has been on the leading edge of it. Improving shared Kotlin interop with more idiomatic Swift is one of those things (e.g. generics)

I really like the decision process you had taking into account your team competencies, product goals, and technology capabilities. I wrote and presented on this as well.

Thread Thread
 
angelolloqui profile image
Angel G. Olloqui

Nice! Kotlin Native is indeed maturing quickly, and I am starting to think that it might be my choice if I would have to start with Playtomic from scratch today.
I would love to see a deeper article of your experience, especially on your day to day problems. How is the developer experience? can you for example debug kotlin code from xcode? how fast is the compilation process? how well does the bridging work? how much of your code gets reused at the end of the day? how is it forcing you to adopt a particular architecture... so many questions :)

Thread Thread
 
kpgalligan profile image
Kevin Galligan

"How is the developer experience?"

Improving. The tooling and libraries need some work, but Kotlin is made by Jetbrains, and they are arguably the best tooling vendor in the business, so it'll be great soon. The community is also growing quickly, largely transitioning from Android rather than coming from "out of nowhere". We've made Droidcon NYC 2019 a very Multiplatform conference: medium.com/@kpgalligan/kotlin-ever...

"can you for example debug kotlin code from xcode?"

Yes. We make that plugin: medium.com/hackernoon/kotlin-xcode...

"how fast is the compilation process?"

Not super fast, but the general dev process is to code on the JVM side first, which is more optimized, then rebuild the Xcode framework and call it from iOS. The next major version of Kotlin should be focused on various performance improvements (compilation being one of them).

"how well does the bridging work?"

It currently presents to Swift/Objc as an Objc library. The usual Swift Objc interop issues will apply, but the generated header takes advantage of many of the naming you can insert for Swift. You can also now have generics (I highlight that because we did that too, wink: github.com/JetBrains/kotlin-native...). With ABI stability, direct Swift interop will be more of a priority. Possibly early next year, but there is no timeline yet from the team.

"how much of your code gets reused at the end of the day?"

It is "optional sharing", as the shared code produces a framework that can be called from Swift/Objc. You can share networking, storage, threading, architecture, etc. That will be difficult to introduce on an existing app, or perhaps the existing iOS team would prefer to start with smaller pieces, so just combine database and remove core data. That kind of thing. It depends. UI, not yet. This is defintely for "business logic" and architecture. Critically, of course, you can share tests.

"how is it forcing you to adopt a particular architecture"

Related to above, if you are doing discrete modules (data, tax calculations, networking), you don't really need a shared architecture. If you want common architecture, that's a more complex question, and will have simpler answers soon. A lot of the Kotlin/Native world is waiting on multithreaded coroutines. That will trigger several architectulal libraries built on top in quick succession. Questimate, later this year. Today, you have a few options. I just mention the coroutines one as when that emerges, the "today" options will become the "yesterday" options pretty quickly. We will have a few architecture talks at Droidcon, though.