· 11 min read Posted by Kevin Galligan
MVP for iOS and AND, PDQ
This is a follow up to the debut of sqldelight and rxjava. For background, read the original Doppl post.
In summary, Doppl is a set of tools and libraries built around j2objc. The end result is an Android-centric cross platform code sharing framework, attempting to be the most efficient way to build native apps for Android and iOS.
This should be interesting to you if:
- You’re building a mobile product and you’ve decided to build native
- You have some non-trivial business logic (data, offline, etc)
Android has some of the best open source frameworks available on any platform. The plan is to leverage these and share as much non-UI logic between Android and iOS as possible. UI’s will be built with the native tools.
Key points:
- Android is 100% Android. Nothing “cross platform”. Doppl will not impact Android development time or introduce any 3rd platform risk.
- iOS is Objective-C, from Java.
The concept is to share your business logic without impacting development efficiency or risk. You simply subtract the iOS-side business logic effort.
Got it? OK.
Today we’re going to talk a bit about framework additions to Doppl, but more importantly their application, and the vision of how you would architect apps, and where the real efficiencies could be had. More simply, the technical ‘why’.
MVP?
There’s been a lot of talk recently about MVP architecture (and MVVM, and probably other MV_ options). The basic idea is to separate the logic and its coordination of the view from the actual view logic. Without getting too wordy, this will help with testability and reduce complexity.
J2objc, by design, doesn’t deal directly with UI. Out of the box, its a pretty raw environment, compared to your standard Android dev. Once you include a solid set of Android libraries, you can model the whole MVP stack and share all of that logic with your iOS code. You can do your “best practice” architecture once.
The sqldelight sample has been augmented as follows to demonstrate.
Dagger has been added for dependency injection, which isn’t strictly necessary, but its there.
A few sample tests have been written using a Doppl test runner that delegates to Robolectric on the java side, and a simple runner in iOS which provides a testing Android context.
The iOS unit tests don’t currently run on the command line. You build them and run them from an app in xcode. The Doppl test runners are nowhere near as involved as Robolectric. You just get access to a Context for testing. The code needs normal threading to work. It works well, though.
There are only a few sample tests, but you can see how things would work in a more robust app. Again, to be clear, if this were a real app, these would be some shit tests, but the demo shows the architectural building blocks are readily available.
Structurally, there are presenter classes, which have host interfaces which implement the calls that wind up actually manipulating the UI. The goal is to have the UI logic layer be as narrow as possible, to maximize code reuse.
If you look at the app code, the Android UI is implement in ‘com.example.sqldelight.hockey.ui’. This is primarily code for Activities, row definitions for ListView, and a simple Adapter that delegates to the CursorWrapper from the data folder. Glue code, basically. On the iOS side, this same functionality is implemented with UIViewController and UITableViewCell implementations.
Then you have your logical 3rd UI, which is your tests. We did a little mocking of presenter level code to check that things were called as expected. Anyway…
Note to reader. This was supposed to be a quick “off the top of my head” post and about MVP. Then like half of it was about the funky UI interface I put in the demo. I realize that, but rather than trying to perfect the post, then not posting, I’m leaving it as is. Skip to “Yeah, OK, but Why?” if you’re in a rush.
For comparison, here’s a row definition in Swift:
class **PlayerTableViewCell**: UITableViewCell, SDDPlayersPresenter\_Row
\{
// MARK: Properties
@IBOutlet weak var playerName: UILabel!
@IBOutlet weak var teamName: UILabel!
@IBOutlet weak var playerNumber: UILabel!
func **getPlayerName**() -\> UIDPTextView\{
return playerName as! UIDPTextView;
\}
func **getTeamName**() -\> UIDPTextView\{
return teamName as! UIDPTextView;
\}
func **getPlayerNumber**() -\> UIDPTextView\{
return playerNumber as! UIDPTextView;
\}
\}
The code above is providing UILabel cast to an interface our shared code can call. The shared code will be populating the UI “directly”.
Now the Java:
public final class **PlayerRow** extends LinearLayout implements PlayersPresenter.Row
\{
@BindView(R.id.player\_name) TextView playerName;
@BindView(R.id.team\_name) TextView teamName;
@BindView(R.id.player\_number) TextView playerNumber;
DPTextView dpPlayerName;
DPTextView dpPlayerNumber;
DPTextView dbTeamName;
public **PlayerRow**(Context context, AttributeSet attrs) \{
super(context, attrs);
\}
@Override protected void **onFinishInflate**() \{
super.onFinishInflate();
ButterKnife.bind(this);
dpPlayerName = new DPTextViewWrapper(playerName);
dpPlayerNumber = new DPTextViewWrapper(playerNumber);
dbTeamName = new DPTextViewWrapper(teamName);
\}
@Override
public DPTextView **getPlayerName**()
\{
return dpPlayerName;
\}
@Override
public DPTextView **getPlayerNumber**()
\{
return dpPlayerNumber;
\}
@Override
public DPTextView **getTeamName**()
\{
return dbTeamName;
\}
\}
Similar. The extra logic is inflating the UI. Notice the DPTextView get methods. They do the same thing as the swift code, except we actually have to create wrapper classes in Java. This is a case where the Objective-C actually makes things easier. We just added an interface to the existing UILabel class.
In the shared presenter code, here’s the definition of ‘Row’:
public interface **Row**
\{
DPTextView **getPlayerName**();
DPTextView **getTeamName**();
DPTextView **getPlayerNumber**();
\}
and the code that actually interacts with the “Row”:
public void **fillRow**(Player player, Team team, Row row)
\{
**//Notice we are setting the UI text from shared code, and the native UI has adapters to implement 'setText' on each platform**
row.**getPlayerName**().setText(player.first\_name() + " " + player.last\_name());
if(row.getTeamName() != null)
\{
row.getTeamName().setText(team.name());
row.getPlayerNumber().setText(String.valueOf(player.number()));
\}
else
\{
row.getPlayerNumber().setText(String.valueOf(player.number()) + "-" + team.name());
\}
\}
The ‘DPTextView’ is a bit of an experiment (it’s also kind of breaking MVP, but go with it). The basic UI toolset of Android and iOS are pretty similar until you get to the more complex stuff. I’ve defined a tree of wrapper interfaces which can be used in shared code, with implementations in Java and Objective-C. As mentioned, the Objc side is actually simpler because we added a “category” to the UIKit classes, which allows us to implement our wrapper interfaces and add necessary methods.
So, for example, DPTextView defines:
void setText(String s);
In Objective-C we have UILabel+DPTextView.h, with…
@implementation UILabel (DPTextView)
\- (void)setTextWithNSString:(NSString\*)text\{
\[self setText:text\];
\}
@end
Anyway, the ‘DP’ UI stuff is new and I’m not sure practical, but you get the idea. Make the UI as dumb and narrow as possible. I think in most cases the interaction will be more coarse-grained. You’ll call the host with some set of data to display and route it to your UI components in native code. However, you can push some of that into the shared layer, as demonstrated.
Yeah, OK, but Why?
The takeaway here is you can architect your Android app using current “best practices”, without any special consideration to “cross platform”, other than which package your classes go into, and making absolutely sure you keep the UI bits in the ‘ui’ package(s). Your dev efficiency for the Android side is 100%, and since the native platform is the fastest way to build for a single platform, your Android build is as fast as it can possibly be. That means efficient product iterations.
Similarly, unique to any cross platform solution, its only half of a cross platform solution. The Android side is 100% Android. As much as I am an Android fan, Android dev is more risky. We’re supporting more concurrent versions. There are many screen sizes. There are many funky devices. The least risky way to build for Android is using Android Studio and Java. The “risk”, if there is any, is on the iOS side, which has a far more homogenous device and version situation.
So, if you have a clean separation of ‘ui’ and ‘logic’, which is generally recommended in best practice discussions, you can have that same logic running in Xcode. To then produce your iOS app you have to simply wire up the UI components. Xcode, for all of the grips I might make about it, is definitely the easiest place to craft an iOS front end.
To get a sense of how the iOS side of the code works, here’s the code for the team screen UIViewController in Swift:
**//The team screen controller. Notice SDDTeamPresenter\_Host. That's the callback interface for the team presenter**
class **TeamViewController**: UIViewController, UITableViewDelegate, UITableViewDataSource, SDDTeamPresenter\_Host \{
var selectedTeam = Int64(SDDPlayersPresenter\_NOTHING);
let showTeamPlayersId = "showTeamPlayers"
**//When the user clicks a row. This is part of the Host interface**
public func **showTeamPlayers**(withLong id\_: Int64) \{
//The segue
self.selectedTeam = id\_
self.performSegue(withIdentifier: showTeamPlayersId, sender: self)
\}
**//The presenter instance**
var presenter: SDDTeamPresenter!
@IBOutlet
var tableView: UITableView!
override func **viewDidLoad**() \{
super.viewDidLoad()
**//Dagger injection. Inject presenter fields**
presenter = SDDTeamPresenter()
let appDelegate = UIApplication.shared.delegate as! AppDelegate
let presenterComponent = SDDDaggerPresentersComponent\_builder().appModule(with: appDelegate.appModule).build()
presenterComponent?.inject(with: presenter)
presenter.init\_\_(with: self)
self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
// Do any additional setup after loading the view.
\}
**//Swift's version of onDestroy (ish). Memory is different on iOS, so you need to close things explicitly sometimes**
**deinit**
\{
presenter.close()
\}
**//UITableViewDataSource and Adapter on Android are pretty similar. CursorWrapper is a pretty simple delegate to provide the same functionality cross platform.**
func **tableView**(\_ tableView: UITableView, numberOfRowsInSection section: Int) -\> Int \{
return Int(presenter.**getCursorWrapper**().rowCount());
\}
func **tableView**(\_ cellForRowAttableView: UITableView, cellForRowAt indexPath: IndexPath) -\> UITableViewCell \{
let cell = self.tableView.dequeueReusableCell(withIdentifier: "TeamTableViewCell", for: indexPath) as! TeamTableViewCell
let team:SDDTeam = presenter.**getCursorWrapper**().atRow(with: Int32(indexPath.row)) as! SDDTeam
**//Leverage presenter to perform any display formatting logic**
presenter.fillRow(with: team, with: cell)
return cell
\}
func **tableView**(\_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) \{
**//In this example we just turn around and call into the host. In a real example this might simply be handled in Swift directly, but to demonstrate how to keep all logic in the presenter, we're calling into the presenter. The presenter has the data, so we're just passing in the position.**
presenter.rowClicked(with: Int32(indexPath.row))
\}
// MARK: - Navigation
override func **prepare**(for segue: UIStoryboardSegue, sender: Any?) \{
if segue.identifier == showTeamPlayersId
\{
if let destinationVC = segue.destination as? **PlayersViewController** \{
**//Tell PlayersViewController which team to show**
destinationVC.teamId = self.selectedTeam
\}
\}
\}
\}
There’s some verbose code in there, but no real “logic”. It’s all glue code. The important point is that its “dumb”. Given a functional Android app and designs, you should be able to quickly and predictably construct the iOS side.
For pretty standard apps, a production version of this framework would deliver the most efficient/least risky way to build a native experience across Android and iOS.
Testing
Currently, the testing needs to keep pretty close to just logic and basic threading. Doppl is in an early stage. That’s how it goes. You can use Robolectric on the Android side, but anything beyond providing a testing Context won’t be runnable on iOS. That’s mostly UI stuff, though, so its not a huge deal.
Normal Junit tests generally run OK. To run Robolectric, add a special runner:
@RunWith(DoppelRobolectricTestRunner.class)
public class HomePresenterTest
To get a context:
DopplRuntimeEnvironment.getApplication()
You run iOS tests in an app. There’s a separate target for the tests in Xcode.
Other Stuff
Minor things. The shared code uses retrolambda, and that code works fine on both platforms.
RxJava itself has tests passing > 99%, and the remainder have clear fixes. Mostly around large memory release stacks. However, j2objc is not garbage collected, and there’s some significant, non-trivial work to sort that out 100%. The parts used in the sample app don’t leak memory, but some will. Just FYI.
Running Code
I pulled the sqldelight sample out of the full project, to simplify playing around with it.
As presented, you don’t need to run the Java transpile. You can simply run the iOS build. I don’t know if we’d commit the generated iOS code in a production build. It’ll result in a lot of noise in your commits. However, this is a sample, and you want to poke things. Fixing configs isn’t fun, so I’ve attempted to minimize them.
So, onward. Starting to work with some pilot partners. Reach out if you’d like to get more info.