Breaking the monolith: Scalable Native Apps II (iOS implementation)

In this post, I will continue where I left off on part one “Breaking the monolith: scalable native apps,” by giving examples of how large projects can be decomposed to allow faster iterations. I will try my best to keep the technical jargon to manageable levels so that the concept is not lost (if you are not a developer familiar with some of the tools mentioned here).

Before we get started, here are a list of things that are recommended (but not required) to make the best of this piece:

  1. Basic knowledge of CocoaPods
  2. Xcode for iOS and Android Studio for Android
    1. For android, you will also need to setup a maven/Artifactory server
  3. A source code management system, such as GitHub or BitBucket

iOS sources can be downloaded here:

  1. Main application
  2. Order processing module

To simplest approach to modularizing an iOS application is to create a Podfile and integrate CocoaPods. From that podfile, a list of frameworks or libraries can then be listed as a dependency. CocoaPod will handle the dirty work of downloading these dependencies and making them available for the build. In our CookR.io example our podfile is pretty simple (integrating only the order processing module) but can be used to integrate any number of open source projects available on the web.

Create and version the module

In the attached sources, the first step was to create the module (private specs repo in CocoaPod parlance) with pod lib create Cookr.io.OrderProcessing. This starts a wizard that creates a new Xcode workspace with your module and optionally tests and an example project that may be used to functionally test your code. At the end a .podspec file is generated, this file is then tweaked to specify the version we are creating as well as any source files or resources that must be bundled into our framework.

Screen Shot 2017-04-23 at 10.58.30 AM

To keep things short and spicy, the order processing module merely defines one storyboard and a two view controllers. The first controller allows a user to choose the global flavour to apply to their meal. The second controller simply shows an image and the allows the user to end the flow (Done navigation button) and send the selected flavour back to the flow initiator.  This seems awfully simple, but it is a powerful feature of this architectural pattern.  In this example the initiator is the main application. However, this flow could be invoked from *anywhere* including another module!

Screen Shot 2017-04-23 at 11.01.33 AM

With a module defined, and published as per instructions in the CocoaPods guide for creating private specs repos, our main application can then consume this module without knowing the intricate details of how it is actually implemented. To do this, we create a Podfile in the application project (pod init) and list our module as a pod. Then run pod update to install the module and make it available to the project.

 

Screen Shot 2017-04-23 at 11.07.49 AM

Use the module APIs & Initiate the flow

With the above step complete, we can now get to the fun parts – using APIs exposed by the module. In our CookR.io example, this means invoking our OrderFlowIntitator factory:

import CookROrderProcessing
// ...
// Create a flow controller and present it - this hands control over to the
// the module
if let flowController = OrderFlowInitiator.flowController(with:nil) {
    self.present(flowController, animated:true)
}

This block of code hands the presentation and business logic/flow over to the module. Easy right? Now, there are some caveats that must be handled when building in the manner. The first one, is that module authors should keep the external interfaces very simple so that the consumer does not have to deal with messy details such as how to correctly load the storyboard(s) and controllers that the module defines. Afterall, we are striving for better decoupling! The second is that an application can have many frameworks and resource bundles. If the module uses resource bundles and storyboards, then care must be taken to load from the correct locations:

open class OrderFlowInitiator: FlowInitiator {
   // factory method for instantiating a flow controller
   public class func flowController(with parameters: Dictionary<String, Any>?) -> UIViewController? {
     return OrderFlowInitiator().create(with:parameters)
   }
 
  // internal implementation that conforms to the flow initiator protocol
  // ideally FlowInitiator would be a public protocol that can be shared across multiple modules.
  // for the purpose of this example, declaring it inside this module is sufficient
  func create(with data: Dictionary<String, Any>? = nil) -> UIViewController? {
     print("OrderFlowInitiator: start flow")
 
     // get the framework bundle that this class belongs to
     let frameworkBundle = Bundle(for: OrderFlowInitiator.self)
 
     // use framework bundle to lookup the resource bundle that our module's resources will be 
     // packaged in (see the s.resource_bundles mapping in podspec)
     let bundlePath = frameworkBundle.path(forResource: "CookROrderProcessing", ofType: "bundle")
     let resourceBundle = Bundle(path: bundlePath!)

     // with the correct bundle, create a storyboard and return the initial controller
     return UIStoryboard(name: "OrderProcessing", bundle: resourceBundle).instantiateInitialViewController()
   }
}

This code specifically always returns the initial view controller of the storyboard. If the module defined multiple storyboards with different flows, then the appropriate logic would also have to be added to OrderFlowInitiator.flowController(with:) method to determine which board/controller to setup and return to the caller.

(Optional) Return data to the flow initiator

When the module has completed its flow, depending on requirements, data can be returned to the caller. There are a number of ways to do this – in CookR.io example, we simply post a notification that can be handled by the application:

@IBAction func completeOrderFlow(_ sender: Any) {
  print("Order flow complete!")
  self.navigationController?.dismiss(animated: true, completion: nil)
 
  // deliver the outcome of the flow back to whichever module (or main app) that initiated the flow
  var flowResult = Notification(name: Notification.Name(rawValue: "flow.result"))
  flowResult.object = self.selectedFlavour
  NotificationCenter.default.post(flowResult)
 }

 

DEMO

The next post in the series will explore how to accomplish the same pattern in android.

#TillNextTime

– Martello Jones

Published by Martello Jones

I enjoy working on interesting projects that make life better.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s