Hybrid SDK Architecture at RevenueCat

Adding a layer to reduce complexity

SDK Illustration
Maddie Beyl

Maddie Beyl

PublishedLast updated

At RevenueCat, maintaining the code for each of our (currently 6!) SDKs presents a unique development challenge. In addition to our iOS and Android SDKs, we provide SDKs for four different hybrid frameworks: Flutter, ReactNative, Unity, and Cordova. These hybrid frameworks allow developers to write a single codebase to deploy an iOS and Android app (and sometimes desktop and web, too!). Since our mission is to help developers easily monetize and grow their app business, we knew early on that it’d be important for us to provide SDKs for popular hybrid frameworks.

On the one hand, the opportunity to learn all of those languages and frameworks is exciting – it’s one of the main reasons I joined the team. But as with most interesting technical endeavors, it also introduces some major challenges. For one thing, releasing a new feature on all 6 platforms involves context-switching between development environments and languages. In this post, I’m going to talk about how we’ve architected our hybrid SDKs to reduce maintenance costs and release faster.

How our hybrid SDKs work

Making purchases and staying in sync with the app stores requires interacting with native in-app purchasing libraries like Google’s BillingClient or Apple’s StoreKit. This means our hybrid SDKs need to pass calls through our native SDKs to the underlying app stores. As part of that process, we need to transform the hybrid framework languages (like Dart, Javascript, or C#) into languages expected by our natives, like Swift or Kotlin. 

A hybrid call flow involves three steps:

1. The call is created at the message-passing layer

First, a RevenueCat user calls a function provided by one of our hybrid SDKs. The hybrid SDKs themselves are fairly simple message-passing interfaces. They accept different objects depending on the platform — for example, the Android ReactNative layer accepts WriteableMap, whereas Cordova accepts JSONObjects. We’ve factored in best practices for each language to make our APIs feel “at home” in each framework. This is the only layer our customers need to interact with.

2. The call is passed to purchases-hybrid-common

Rather than immediately pass from a hybrid SDK to, say, our Android SDK, we’ve created an intermediary wrapper library called purchases-hybrid-common. This layer abstracts out some common type transformations and then routes the call to our native SDKs. Each of our hybrid SDKs depends on purchases-hybrid-common, which in turn depends on our Apple and Android native libraries.

3. The call interacts with the native layer and app store-specific functionality

Once purchases-hybrid-common has transformed the hybrid call, it makes a call to one of our native SDKs. All platform-specific purchasing logic, like retrieving products or starting a purchase with one of the stores, is handled here. Then the results are propagated back up the call chain.

Example

It’s tough to understand how this works without an example, so let’s walk through the method call flow for “getOfferings”. “getOfferings” is one of the most important methods in our SDK – it retrieves the packages that are available for the customer to purchase (for example, an offering might have packages for yearly, monthly, and lifetime subscriptions).

This method is a great example because it is asynchronous and returns a custom type, both of which require special handling for a hybrid call. First we’ll see how the call is propagated through to our native library, then we’ll follow the data back up through the callbacks to our user.

getOfferings’ call flow. Steps a-c correspond with steps 1-3 described above. Steps d-f follow the call back up through callbacks, and will be described in detail below.

First, the call is created at the message-passing layer. Flutter uses Dart, so the original call from your app might look something like step A from the diagram above:

1// An example call to getOfferings from a Flutter app (see diagram step a)
2
3try {
4  Offerings offerings = await Purchases.getOfferings();  
5  // Display packages for sale
6  
7} on PlatformException catch (e) {    
8  // optional error handling
9}

This routes into purchases-flutter (our Flutter SDK) and its getOfferings method, defined in purchases-flutter.dart:

1//An example call to getOfferings from a Flutter app (see diagram step a)
2
3static Future<Offerings> getOfferings() async { 
4  final res = await _channel.invokeMethod('getOfferings'); 
5  return Offerings.fromJson(   
6    Map<String, dynamic>.from(res), 
7  );
8}

Each hybrid framework has its own unique way of determining which platform the app is currently running on, and Flutter uses Platform Channels. “invokeMethod” attempts to call the “getOfferings” method on the platform. 

Imagine we’re running an Android app. Flutter recognizes an Android platform channel and routes the Dart call to our Android FlutterPlugin, defined in PurchasesFlutterPlugin.java:

1// PurchasesFlutterPlugin.java, which has implemented FlutterPlugin, is now attached to an application context and receives method calls (see diagram step b)
2
3public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
4  switch (call.method) {
5case "getOfferings":
6  CommonKt.getOfferings(getOnResult(result)); // diagram step b
7  break;
8case "logIn":
9  String appUserID = call.argument("appUserID");
10  CommonKt.logIn(appUserID, getOnResult(result));   break;
11
12// etc
13}
14}

The MethodCall parameter wraps the method name and any arguments (see the “logIn” case for a call with parameters). getOfferings doesn’t accept any parameters, so the call simply continues along the route. We’ll go into OnResult in more detail when we walk back up the callback chain.

This brings us to Step 2: passing the call to purchases-hybrid-common (and the common.kt file within it). This class defines wrappers around our Android and iOS interfaces. If a call takes parameters, it might parse those into the types required by the native API. With getOfferings, common.kt just passes the call along to our native Android Purchase library. We’ll go into the callback in more detail when we discuss the route back “up” to Flutter.

1//common.kt from purchases-hybrid-common calls into the native purchases-android SDK (see diagram step c)
2
3fun getOfferings(
4  onResult: OnResult
5) {   
6  Purchases.sharedInstance.getOfferingsWith(onError = {
7    onResult.onError(it.map()) }) {
8    onResult.onReceived(it.map())   
9  }
10}

In Step 3 (Step C in the diagram), the call interacts with the native layer and any app store-specific functionality required. The Android Purchases library implements “getOfferingsWith”, which is a Kotlin utility function–it simply calls getOfferings, converting the listener to lambda functions.

1
2// purchases-android, our native Android SDK, defines getOfferingsWith and a callback type for the result (see diagram step d)
3
4fun Purchases.getOfferingsWith(   
5  onError: ErrorFunction = ON_ERROR_STUB
6  onSuccess: ReceiveOfferingsSuccessFunction
7 ){   
8  getOfferings(receiveOfferingsListener(onSuccess, onError))
9}
10
11internal fun receiveOfferingsListener(
12  onSuccess: ReceiveOfferingsSuccessFunction,
13  onError: ErrorFunction
14) = object : ReceiveOfferingsListener {
15  override fun onReceived(offerings: Offerings) {
16    onSuccess(offerings) // diagram step “d”
17 }   
18 
19 override fun onError(error: PurchasesError){
20  onError(error)   
21 }
22}

Let’s imagine purchases-android successfully retrieves the offerings from the backend and invokes the “onReceived” method above to pass the offerings back (Step D in the diagram). Now we’re ready to respond back up the call chain. Most of the code in the rest of the post is repeated from above, but let’s dive into the callbacks — this is where things get interesting.

1// purchases-hybrid-common receives a result from purchases-android and maps it to a json map (see diagram step e)
2
3fun getOfferings(
4  onResult: OnResult
5) {
6  Purchases.sharedInstance.getOfferingsWith(onError = {
7    onResult.onError(it.map())
8}) {
9  onResult.onReceived(it.map()) // diagram step “e”
10 }
11}

Remember how purchases-hybrid-common called “getOfferingsWith”? Because the native call was successful, “onResult.onReceived(it.map())” is invoked, where “it” is the Offerings object sent in the onSuccess for getOfferingsWith. So why are we calling map() on the Offerings? What does that do? 

This is where the real need for purchases-hybrid-common comes in. We can’t pass a Kotlin type directly back to Flutter’s PlatformChannels or to any of the other cross-platform framework’s messaging layers. Each framework’s messaging layer accepts only specific types, in order to standardize and speed up message processing from any given plugin. For instance, Flutter receives messages conforming to a standard codec, while Cordova’s CallbackContext accepts strings, byte arrays, JSONArrays, and JSONObjects.

We use purchases-hybrid-common to deserialize the Kotlin Offerings object into a HashMap, because we’ve found HashMap to be the easiest middle ground for the types the hybrids expect. By deserializing in purchases-hybrid-common, we can define the keys of the mapping in one place, avoiding duplicate code and parsing bugs. Then each of our hybrid SDKs handles the conversion from HashMap into the type(s) its messaging layer expects, without needing to know any of the specific key-value pairs.

Here’s the code for map() in our example:

1fun Offerings.map(): Map<String, Any?> =
2  mapOf(
3    "all" to this.all.mapValues { it.value.map() },
4    "current" to this.current?.map()   
5)
6
7private fun Offering.map(): Map<String, Any?> =
8  mapOf(
9    "identifier" to identifier,
10    "serverDescription" to serverDescription,
11    "availablePackages" to availablePackages.map { it.map(identifier) },
12    "lifetime" to lifetime?.map(identifier),
13    "annual" to annual?.map(identifier),
14    "sixMonth" to sixMonth?.map(identifier),
15    "threeMonth" to threeMonth?.map(identifier),
16    "twoMonth" to twoMonth?.map(identifier),
17    "monthly" to monthly?.map(identifier),
18    "weekly" to weekly?.map(identifier)
19 )

Notice that purchases-hybrid-common maps the children of Offerings as well.

Now purchases-hybrid-common can pass the mapped result back up to the OnResult created in purchases-flutter, and we’re back in the Flutter library. 

Now that we’ve passed the Offerings HashMap to our Java “onResult.onReceived”, we can pass that to the Flutter “Result”. Flutter’s standard codec works well with HashMaps; however, if this was a Cordova app, we’d have to convert to a JSONObject.

1// PurchasesFlutterPlugin.java receives the result from common.kt
2
3private void getOfferings(final Result result) {
4  CommonKt.getOfferings(getOnResult(result));
5}
6private OnResult getOnResult(final Result result) {
7  return new OnResult() {
8  @Override
9  public void onReceived(Map<String, ?> map) {
10    result.success(map);
11  }
12  @Override
13    public void onError(ErrorContainer errorContainer) {
14    result.error(String.valueOf(errorContainer.getCode()), errorContainer.getMessage(), errorContainer.getInfo());
15    }   
16  };
17}

Each hybrid framework has a different way of handling asynchronous coding. Our OnResult Java type provides a uniform interface for wrapping the purchases-hybrid-common callbacks and their HashMap results, but each hybrid must then propagate the data according to its language/tooling.

Flutter uses the Dart concept of a Future (similar to Promises in ReactNative). Flutter uses this Result to fulfill the Future, so when we call “result.success(map)”, we finally receive our Offerings map! The final step is to re-create an Offerings object (this time, the one Dart knows about) from the Dart Map<String,dynamic>.

1// purchases-flutter receives a map from purchases-hybrid-common, and creates an Offerings object from it (see diagram step f)
2
3static Future<Offerings> getOfferings() async {
4  final res = await _channel.invokeMethod('getOfferings');
5  return Offerings.fromJson( // diagram step “f”
6  Map<String, dynamic>.from(res),
7 );
8}

And there we have it: our offerings! 

Conclusion

I have to admit, this call flow was one of the most intimidating things for me to learn when I joined RevenueCat. Perhaps it was so tricky for me because I had only done Android development before, so I had to learn a lot of new languages and frameworks. But there is some additional complexity in understanding how we’ve architected our code to reduce development overhead in the future.

Prior to creating the purchases-hybrid-common library, each hybrid framework had its own logic to deconstruct our classes into acceptable types. We ended up copying JSON-like parsing into all of our hybrid SDKs for all of our types. Unifying this logic in purchases-hybrid-common made life easier and reduced the number of bugs.

That said, there are definitely some areas for improvement. For instance, we’d love to convert as much of our Java/Obj-C code to Kotlin/Swift as we can. We’re also considering adopting some third-party serialization libraries for all of that JSON mapping. 

One step we’ve already taken is to expose a “rawData” property on some of our native classes, which saves the backend JSON object. Not only does this help with  debugging and enable our customers to access backend properties without a release, but it could also enable us to simplify or even remove some of the parsing in purchases-hybrid-common.

We’re always looking to improve our SDKs and would love your thoughts on our current setup. And of course, we’re open source if you want to contribute! 😉

We’ve added a “You can do this!” label to the issues in our repositories  that we think outside contributors can help with. Hopefully this post inspires you to contribute to a hybrid repository or help us improve our hybrid communication stack.

In-App Subscriptions Made Easy

See why thousands of the world's tops apps use RevenueCat to power in-app purchases, analyze subscription data, and grow revenue on iOS, Android, and the web.

Related posts

How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake
How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake
Engineering

How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake

Challenges, solutions, and insights from optimizing our data ingestion pipeline.

Jesús Sánchez

Jesús Sánchez

April 15, 2024

How RevenueCat handles errors in Google Play’s Billing Library
How RevenueCat handles errors in Google Play’s Billing Library  
Engineering

How RevenueCat handles errors in Google Play’s Billing Library  

Lessons on Billing Library error handling from RevenueCat's engineering team

Cesar de la Vega

Cesar de la Vega

April 5, 2024

Use cases for RevenueCat Billing
Engineering

Use cases for RevenueCat Billing

3 ways you can use the new RevenueCat Billing beta today.

Charlie Chapman

Charlie Chapman

March 21, 2024

Want to see how RevenueCat can help?

RevenueCat enables us to have one single source of truth for subscriptions and revenue data.

Olivier Lemarié, PhotoroomOlivier Lemarié, Photoroom
Read Case Study