December 19, 2022

Inside our Cross-Platform Mobile SDKs

James Ellis
,
Principal Mobile Engineer
@ Appcues

Using Appcues in both your web and mobile applications allows you to deliver beautiful, contextual, targeted messaging to your users - and drive deeper adoption of your products. Our initial focus for Appcues Mobile was on building a great native foundation in our Swift and Kotlin SDKs, for iOS and Android. However, Appcues is not just for native mobile applications. We also support several of the most popular cross-platform solutions that our customers are using to bring mobile value to their end users. Our mobile engineering team has been learning more about all of these frameworks, and how to best enable access to our mobile SDK solutions.

This post is targeted to mobile engineers, to help you understand how to build libraries that can be used across the variety of popular cross-platform frameworks available today. We’ll discuss a few of the frameworks that have received the most demand from our customers: React Native, Flutter, Ionic and Xamarin / .NET.

In each section, we’ll use the example of the common screen tracking call in the Appcues SDK to demonstrate implementation and usage. Some other details in code snippets will be omitted for brevity. For example, most snippets assume that the SDK initialization function has already been invoked, and that the native SDK instance is valid. Full implementation details and example app usage can be found in the source code repositories linked at the end of this post.

React Native

Appcues supports React Native applications through a native module. This is a standard approach for exposing native platform functionality in iOS and Android libraries through a common JavaScript interface used in the React Native application. Including an npm package for @appcues/react-native in your package.json will bring in all the necessary components to use Appcues.

Using a native module wrapper allows us to make all of the underlying native iOS and Android functionality available, without having to build and maintain another full SDK implementation in JavaScript. We’ll be able to provide faster updates, and more easily provide the underlying native capabilities. The functionality of Appcues on React Native will then be in full parity with an app written in Swift or Kotlin. This section will explain how we implemented this native module.

Access to NativeModules is imported from the react-native package.

When one of the functions in the common JavaScript interface is invoked, it will call into the underlying native iOS or Android SDK, depending on which platform the React Native application is running on.

The native module includes Swift (iOS) and Kotlin (Android) implementations that bridge the JavaScript calls over to the underlying native implementation. The React Native framework installs these platform specific wrapper implementations in a way that is fully abstracted from the consumer React Native application. When building the iOS application, the package automatically includes the native SDK through Cocoapods. When building the Android application, the package automatically includes the native SDK from Maven Central, through the Gradle build system.

In the iOS native module implementation we exposed an Objective-C interface, defined using some special React Native macros. This interface tells React Native how to map the JavaScript calls over to the iOS side. The snippet below is focusing on the screen function used above, using the RCT_EXTERN_METHOD macro.

A Swift source file then provides the implementation of these definitions, completing the job of interfacing with the underlying native Appcues iOS SDK.

For the Android implementation, a Kotlin file is used to define an implementation of a ReactPackage. This package returns the list of supported NativeModule instances - AppcuesReactNativeModule in this case.

The AppcuesReactNativeModule Kotlin source then uses special @ReactMethod attributes to decorate the functions implementing the shared JavaScript interface.

This approach allows Appcues customers using React Native to benefit from the full functionality of our native SDKs, through a thin wrapper layer. When updates are made to the core native SDK libraries, those changes can also be immediately made available to React Native applications. This native module also supports usage in the Expo bare or managed workflows. Our native module is open-source and provides an example application to demonstrate usage.

Flutter

Appcues Mobile supports Flutter applications through a plugin package. This is the standard approach for exposing native platform functionality in iOS and Android libraries through a common Dart interface used in the Flutter application. Including a pub.dev package for appcues_flutter in your pubspec.yaml will bring in all the necessary components to use Appcues.

The pubspec.yaml file declares the plugin and the supported platforms, indicating the classes to use for the implementations on each platform.

A common Dart interface defines the functions available to the Flutter application using this plugin. The plugin uses a MethodChannel as the mechanism to invoke functionality on the native SDKs.

When one of the functions in the common Dart interface is invoked, it will use this _methodChannel to call into the underlying native iOS or Android SDK, depending on which platform the Flutter application is running. Binary serialization is used to pass data values through this plugin layer.

The plugin package includes Swift (iOS) and Kotlin (Android) implementations that bridge the Dart calls over to the underlying native implementations in a way that is fully abstracted from the consumer Flutter application. When building the iOS application, the package automatically includes the native SDK through Cocoapods. When building the Android application, the package automatically includes the native SDK from Maven Central, through the Gradle build system.

In the iOS plugin implementation, an Objective-C class defines the base registration of the plugin, and defers the rest of the core logic to a Swift implementation.

In the Swift plugin class, an instance of the SwiftAppcuesFlutterPlugin is registered as the method call delegate for the method channel defined above. The plugin function calls from the Dart code are sent through the handle function below, which allows the Swift code to switch on the method name, extract parameters and call into the underlying native SDK function as necessary.

For the Android implementation, the AppcuesFlutterPlugin is registered to the “appcues_flutter” method channel, similarly. It is then able to handle calls from the Dart code, sent into the onMethodCall function below. It processes the call name and properties, and delegates into the underlying native Android Appcues SDK.

This approach allows Appcues customers using Flutter to benefit from the full functionality of our native SDKs, through a thin wrapper layer. When updates are made to the core native SDK libraries, those changes can also be immediately made available to Flutter applications. Our plugin package is open-source and provides an example application to demonstrate usage.

Ionic

Appcues Mobile supports Ionic applications through a Capacitor plugin. This is a standard approach for exposing native platform functionality in iOS and Android libraries through a common JavaScript interface used in the Ionic application. Including an npm package for @appcues/capacitor in your package.json will bring in all the necessary components to use Appcues.

The definitions.ts TypeScript file declares the AppcuesPlugin interface that can be used in an Ionic application.

The plugin is registered in index.ts, using the registerPlugin function from @capacitor/core.

When one of the functions in the common TypeScript interface is invoked, it will call into the underlying native iOS or Android SDK, depending on which platform the Ionic application is running on.

The Capacitor plugin includes Swift (iOS) and Kotlin (Android) implementations that bridge the TypeScript calls over to the underlying native implementation. The Ionic framework installs these platform specific wrapper implementations in a way that is fully abstracted from the consumer Ionic application. When building the iOS application, the package automatically includes the native SDK through Cocoapods.  When building the Android application, the package automatically includes the native SDK from Maven Central, through the Gradle build system.  

In the iOS plugin implementation, an Objective-C macro is used to define the plugin and its methods.

The Swift implementation of the AppcuesPlugin class then defines each of these functions, and handles the parameters passed in using the CAPPluginCall provided.

The Android implementation provides a Kotlin AppcuesPlugin class. This class uses some special Capacitor annotations on the class and functions to provide the matching implementations, satisfying the TypeScript definitions.

This approach allows Appcues customers using Ionic to benefit from the full functionality of our native SDKs, through a thin wrapper layer.  When updates are made to the core native SDK libraries, those changes can also be immediately made available to Ionic applications. Our Capacitor plugin is open-source and provides an example application to demonstrate usage.

Xamarin / .NET 6+

Supporting Xamarin or .NET 6+ is a significantly different task than the three wrapper types of projects above. Microsoft’s frameworks use a fundamentally different approach of native bindings that wrap the underlying Java or Objective-C native APIs, which then run on top of the Mono runtime. These frameworks do not expose any mechanism to integrate with XCode or Android Studio projects directly, nor integrate native dependency packages and call them directly from a wrapping layer. There is not a new abstraction layer in another language, like JavaScript, for instance - but instead, wrapping and exposing every part of the underlying native library API to the C# code.

Microsoft is also in the process of evolving all of the traditional Xamarin capabilities into the newer .NET 6+ foundation for iOS and Android development, including what they are calling .NET Multi-platform App UI, or .NET MAUI - which is an evolution of Xamarin.Forms. Traditional Xamarin support will eventually end as this transition completes.

In a .NET MAUI app, you write code that primarily interacts with the .NET MAUI API (1). .NET MAUI then directly consumes the native platform APIs (3). In addition, app code may directly exercise platform APIs (2), if required. (source)

Appcues is supported on Xamarin or .NET 6+ through native binding projects that expose a C# interface to the underlying native iOS or Android SDK libraries. Due to the complexities around supporting the different types of projects possible in Xamarin or .NET 6+, we decided not to distribute a pre-built package on NuGet.org, but may release our binding solution as open source, based on customer interest.

This binding approach provides Appcues customers the most flexible reference to benefit from the full functionality of our native SDKs. When updates are made to the core native SDK libraries, those changes can also be immediately made available to Xamarin or .NET 6+ applications.

Cross platform considerations and learnings

There are a few considerations that should be made when building a library with the intention to support these different frameworks. It is beneficial to build the native libraries with simple public APIs and thoughtful dependency usage. This limits the complexity around wrapping those solutions in different toolkits. The host application should be able to integrate functionality with as few function calls as possible. Keep the API from being too chatty or exposing complex object types and implementation details. Data types exposed in the public API should ideally be as simple as possible - common strings, integers, doubles, boolean, arrays and dictionaries. This is referred to as “deep module” design in the book “A Philosophy of Software Design” written by Professor John Ousterhout at Stanford University. “Simple things should be simple. Complex things should be possible” - Alan Kay

Once native toolkits have been designed and developed, build cross-platform support using the standard platform plugin architectures that each framework provides. These are the techniques covered in each section above. Delivering a simplified integration capability across all platforms will ease the job of developers in other applications trying to use your solutions.

One learning we’ve taken away is the potential complexity of the update process, specifically handling upgrades when new versions of the native SDKs are released. Each new release of the native toolkits likely necessitates a new update of these wrapper libraries. Dependency changes in the native library can cause cascading effects on the cross platform tools - particularly on the Android side where more external dependencies are used and can end up impacting things like the required version of the Android SDK to build.

Additionally, each cross platform toolkit has its own dependency ecosystem that must be managed. For example, Capacitor 3 upgrading to Capacitor 4, or React Native releasing changes to the underlying JavaScript libraries in use. We’ve needed to be mindful of dependency updates to ensure that we provide the best possible integration experience for a variety of customer applications that may be using different versions of cross platform toolkit dependencies. It is inevitable that some time will still need to be invested over the lifetime of the project to keep these cross platform wrappers up to date.

Wrapping up

The four cross-platform frameworks described here represent the majority of our customer demand at Appcues beyond native mobile applications. We’re happy to be able to provide great solutions on each framework for using Appcues Mobile.

We’ve been able to integrate our Appcues SDKs in our customers’ cross platform mobile applications successfully, and are pleased with the overall capabilities these approaches provide. Using native wrapper solutions allows us to keep the core development focused in Swift and Kotlin, build minimal wrapper layers in other toolkits, and deliver the full native functionality to all applications. Not having to maintain additional full SDK implementations on every platform is a big engineering win, in terms of ensuring long term parity, stability, and performance. Keeping these plugins as lightweight as possible has reduced our testing burden, ensured all core logic and functionality lives in the native library, and simplifies future version updates.

Having our Appcues source code open for public evaluation allows us to share our ideas, and gather any feedback you may have. I invite you to please check out the GitHub projects and leave any input for our team in the Discussion section.

James Ellis
Principal Mobile Engineer
@ Appcues
No recommended articles at this moment. Sorry.