Modernizing Soompi — Part 4

Making the Soompi iOS app

Thongchai Kolyutsakul
Viki Blog

--

Soompi iOS App Store page having 4.8 Stars!

We built Soompi iOS app from the ground up and explore concepts like data synchronization, unidirectional data flow, and coordinator pattern. I want to share things I have learned along the way.

This article is divided into 2 sections.

  • Model Layer —How we do data synchronization across pages and and provide optimistic update to interactive views.
  • Navigation using Coordinator pattern — How we separate navigation logic into a dedicated component, the coordinator.

Model Layer

“Strong house needs solid ground layer.
Strong app needs solid model layer.”

— Me 😎

The Challenges

Soompi provides coverage of Korean pop culture, including K-Pop and K-Drama news, exclusives, and videos. Users can interact with news articles by sending a reaction or bookmark to read later. They follow fan clubs to get notifications about latest news. One article/fan club can also appear in different pages in the app. This means we need some data synchronization between pages. For example, if a user likes a BTS article (a Korean boyband) in home page, it has to be reflected in the posts of BTS fan club page as well. We can rely on the server and just reload everything when the page changes, but that’s not ideal.

On top of that, we also want optimistic update. When a user bookmarks an article, we want the icon highlighted immediately even if we are actually still waiting for the API call to complete. On API call failure, we want to be able to revert the change as well.

How do we achieve all these?

Data synchronization

To provide data synchronization between pages, we have find a way to properly deliver changes of data to those pages involved.

I thought about 2 options…

  • Option 1 — Have each page holds a separate copy of the model. Create some sort of a middleman controller to receive and broadcast actions to all pages.
  • Option 2 — Have each page shares the same copy of the model. Data will be stored in some cache controller and each page will only hold a list of id to fetch the full entity from cache. Data update will be applied to this cache, where each page can read it when appropriate (like in viewWillAppear()).

I started by implementing option 1. Each page holds a copy of data. A thin layer was added to provide data synchronization. I called this component ActionBroadcaster. It would receive actions and broadcast it to all pages to update their copies properly. Things seemed to works well. But as actions and pages grows, I realized some problems.

  • It is hard to update views lazily. Option 1 forces the update to all pages even if they are not on screen. This can cause premature updates and waste resources. It’s true we can maintain a flag to check and update only when the page is visible, but it doesn’t feel like a good approach. It’s like we did too much and have to revert back. For Option 2, I can update lazily as needed by reading the cache when views reappear.
  • There’s a lot of boilerplate code. For option 1, I need to setup one observer per action, and per page. More code is generally harder to maintain. For option 2, one reloadFromCache() can handle update for every action.

I end up doing option 2, using Cache.

Setting up the Cache

This is how my cache look like. It is just a simple dictionary wrapper. I’m using a reference type (a class) because I want to share the same instance everywhere.

I also made it a generic class so I hold different resources. Like this…

let feedCache = Cache<Feed>()
let postCache = Cache<Post>()
let fanClubCache = Cache<FanClub>()

To update the cache, there are some more components we have to cover.

First, we have action types. It represents what and how the data should be changed. This is one of the action types, Reaction.

Second, we have CacheController. This will handle action type and make API call to update the cache.

Consider a case where a user follows a fanClub. A FollowAction is passed to theActionController. It updates cache optimistically, calls API, and reverts if the call fails. On success, it does nothing more because cache is already updated.

Reading the Cache

OK, we have the cache updated. How does the data in the cache get to the views in the UIViewController?

We setup 2 functions to update the UIViewController — reload() and reloadFromCache().

The reloadFromCache() function does nothing more than calling tableView.reloadData(). This is usually used in UIViewController's viewWillAppear().

The reload() function triggers the full chain of data fetching. It triggers the fetcher (more this later) to send an API call, update caches and view model, and in turn triggers tableView.reloadData() to reload the cells. This is used on initial loading or when user does a pull-to-refresh gesture.

Optimistic Update

Optimistic update makes the app look more responsive. It tricks the user that an action is successful even if we are actually still waiting for its response. It trades code complexity with better UX.

Imagine you have a custom on/off switch view called LikeSwitch. When user turns the switch on, the switch is highlighted. You send an API call and wait for response. On success, you keep the switch highlighting as is, and then update the state backing that switch. On failure, you turn the switch off without having to update the data.

The problem is that your view may get thrown away while waiting for the response. This LikeSwitch can be in a UITableViewCell in a UITableView. The user can tap the switch, the switch UI is on, scrolls away, and then scrolls back. Here, the UITableViewDataSource will dequeue cell and apply the outdated state data we have on it, rendering the switch as off. We end up losing the optimistic update we made.

To solve this, we need to update not just the view layer (the switch), but all the way down to the model layer (the data backing it). The source of truth has to be updated.

This is where unidirectional data flow comes in.

Diagram of unidirectional data flow — credit ReSwift

To be unidirectional, views cannot update itself without state (data). Your views have to respect the data backing it. The example code below of LikeSwitch class demonstrates how the view is NOT updating the state.

In userDidToggle(), the function doesn’t update anything in the view. It notifies its delegate to have the update happen somewhere else. Later, the updated state will be propagated back to the UIViewController and eventually to the switch. Only at this point, we can update our switch’s state. It might seem like we are taking a detour, but it is necessary to maintain the source of truth.

Possible improvements

  • I discovered later that there are libraries to help enforce more structure to unidirectional data flow like ReSwift and Zendesk’s Suas-iOS. I could have use it to provide more structure to the project.
  • If we have more complex data or need to persist them across app launches, we may need to migrate cache from dictionary to a proper database. That way, performance is better.

Navigation using Coordinator pattern

We all know a problem with Apple’s suggested MVC pattern. Their code samples encourage us to put everything in theUIViewController. Eventually becoming massive and explode.

But we can’t blame Apple for it. They build a foundation frameworks. And foundation frameworks should provide just basic functionalities. It gets you 20% of the way. The other 80% you have to figure on your own. They can’t guide too much on which pattern we should use because it varies from app to app. Connecting the dots is still our job.

Apple could have said this so we don’t blame them for Massive View Controller as much. 😂

Anyway, one big part of most apps is navigation. The logic around navigation itself can be broken into several steps…

  • Redirection — decide whether it should perform that navigation, or redirect to somewhere else (for example — ignore deep links when login page is presented, or bounce out when trying to open an already opened article, etc.)
  • Instantiation —prepare data necessary and instantiate a new UIViewController
  • Presentation — present that UIViewController
  • Tracking — keep track of the navigation tree

That’s a lot.

We can separate these logic out using coordinator pattern. You can read its background and benefits in this blog post. I’ll cover just brief summary and some example how we implemented in Soompi iOS app.

In Coordinator pattern, each UIViewController will not directly present another UIViewController. This logic will be handled by a new component called AppCoordinator.

I set up this AppCoordinator with aUIWindow . I call start() on it to set up the first UIViewController. It can contain some logic. For example, it can decide whether to show content page or login page depending on user’s login state.

Here are some characteristics of AppCoordinator in my design:

  • It handles navigation logic.
  • It handles page setup.
  • It holds all shared controllers (like system wide stuff like API client, data fetcher, and session controller). Each component can gain access to these shared controllers through AppCoordinator
  • It handles delegation from those shared controllers. Those delegates can be for handling no internet error, handling session expired error, etc.

That’s still a lot. Maybe we can split it further. But let’s keep it in one component for now 😬.

Navigation destinations are represented by Navigation enum. Each enum case can have associated values to hold information necessary for setting up that navigation. I like this approach because it makes model layer dependency clear and separated from view layer. The enum is annotated with indirect because some cases refer to Navigation enum itself.

Here is how it look like in Soompi app…

We then pass this Navigation enum to AppCoordinator’s perform() function. It takes care of manipulating the view controller hierarchy inside the window. Each page will use this funnel to open new pages.

This sample code from Soompi app shows perform() function manipulating view controllers in a window, including tabBarController and navigationController.

When the app receives a deep link URL. We can just translate that URL to a Navigation type and perform it. No need for an extra component to handle URLs.

Having a central perform navigation function has many benefits:

  • Each view controller is now isolated. It doesn’t need to worry about how the next navigation will be handled.
  • Each navigation dependency is clear. Navigation enum holds all information needed to set up the page. For example, the Navigation case case share(url: URL, sourceView: UIView?) requires a URL and an optional source UIView to show the share sheet.
  • Navigation logic now has a place to stay. We can put all sorts of navigation related tasks here like checking login requirement and display login page if needed, duplicated page prevention, and traversing the window’s rootViewController to find the current UIViewController.

Coordinator pattern is a simple but powerful. It will make your app more manageable and reusable. A paragraph from this blog here nicely concludes it…

Ultimately, coordinators are just an organizational pattern. There’s no library you can use for coordinators because they’re so simple. There’s no pod you can install and nothing to subclass from. There’s not even really a protocol to conform to.

  • Backward navigation — Currently we only support forward navigations. Backward navigations like back and dismiss doesn’t go through the AppCoordinator. We can add support for it but it will take a bit more effort. Some UIKit components has navigation logic built-in, like UITabBarController automatically changes its selectedViewController upon tapping a tab, or UINavigationBar back button popping the view controller. So we need to intercept all these controls to fully support backward navigation. This can be useful for building tools like App Replay to replicate what user was seeing and help debug user issues more effectively.
  • Child coordinators — If there’s more complex separate navigation branches, we will need to introduce child coordinators to better separate up the concerns. For example, the main coordinator can spawn a login coordinator, let it set up view controllers for login flow, and wait for result through delegate.

Conclusion

I hope you learn something from how Soompi iOS app works. Please let me know if you have any suggestion.

--

--

iOS developer since 2009. Working @Viki in Singapore. I write about iOS development, Swift language, and general software engineers tips.