Skip to content

Instantly share code, notes, and snippets.

@barthap
Last active September 1, 2020 08:41
Show Gist options
  • Save barthap/9a020de38564e9fd955f627e9204920a to your computer and use it in GitHub Desktop.
Save barthap/9a020de38564e9fd955f627e9204920a to your computer and use it in GitHub Desktop.
Draft

Creating custom universal native modules for your Expo bare workflow apps - Part 1

TODO: Make an awesome introduction. The one below is a draft

You love Expo. But sometimes you have to add some native code. With Expo Unimodules you can make your custom native code reusable between any Expo app, and with a little more effort, between any React Native app. What's more - the native code will be loaded automatically when you do yarn install, without the need of any additional linking. And in this article I'll show you how to.

I assume you are using Mac - however it's not necessary unless you follow the iOS part. Also, some terminal commands I use are Mac-specific.

What are unimodules?

TODO: What they are, idea, types of modules, packages, interfaces, singletons, exported modules etc.

@sjchmiela awesome presentation: https://www.youtube.com/watch?v=-9CJZRv7uOY

Unimodules vs "traditional" RN modules (TODO)

"Exported" Universal Modules are like RN modules, but on steroids. Differences:

  • Only one-time installation is required for all of them. And when using Expo (bare workflow), this step is already done.
  • Each module doesn't require additional installation besides yarn install.
  • They can depend on each other on the native side thanks to the built-in API
  • What else??

Prepare your app to unimodules

If you are already using Expo with bare workflow, you are done. If you are using React Native, but without any Expo modules, then you'll have to follow these instructions.

For simplicity, I'll create a plain new Expo bare workflow app:

yarn global add expo-cli
expo init MyBareApp --template expo-template-bare-typescript
cd MyBareApp

Generate your first Unimodule

Create a directory for your Unimodules

You can do it anywhere, it should be inside your project directory*, but it doesn't have to. There are also a few ways to link it to your app project:

  • Yarn/NPM linking
  • Yarn workspaces (assuming you created it under project directory)
  • Publishing to NPM
  • ... ?

*A module which is outside a project's directory will not be able to build as standalone project, because it uses react and react-native as peerDependencies. You can change that in package.json, but it's not recommended. Also, it's native part is included into your app's project doing gradle build or pod install and it's only able to build as part of that project (regardless of its location)

For the sake of this tutorial, I'll create it inside my project directory and cover both Yarn linking and Yarn workspaces.

mkdir MyNativeModules && cd MyNativeModules

Generate your module

Expo CLI has a special command dedicated to generating new modules. It takes a module template and injects names you specify at generation. Default template is fetched NPM expo-module-template@latest, but you can specify your custom template as a path to local directory or NPM package.

Because default template is prepared work with Expo internal module tools - it will not work out of the box. We have two options: we can either do some preparations manually to make it working, or the quick and lazy way: I've done the job already and published module template working out of the box. It's available here.

The easy way

Let's generate a module using that custom template. First, we need to clone it somewhere outside the MyNativeModules (to avoid further problems).

git clone https://github.com/barthap/expo-module-template.git
cd expo-module-template
pwd | pbcopy

The latter command copies current path to clipboard, it's helpful but optional. Now we need to cd into MyBareApp/MyNativeModules and do

expo generate-module --template "$(pbpaste)"

Module generation command will now ask you a few questions about names. I'll call my module just HelloWorld. I provided:

  • How would you like to call your module in JS/npm?: hello-world
  • How would you like to call your module in CocoaPods?: EXHelloWorld
  • How would you like to call your module in Java?: my.modules.helloworld
  • How would you like to call your module in JS/TS codebase: HelloWorld
  • Would you like to create a NativeViewManager?: n - you can type y if you want, but we're not going to use it now.

Now your module is generated and should have the following file structure, assuming you are using Expo CLI 3.25.2 or newer:

hello-world
+-- build
+-- android
|   +-- src/main
|   |   +-- my/modules/helloworld
|   |   |   +-- HelloWorldModule.kt
|   |   |   +-- HelloWorldPackage.kt
|   |   +-- AndroidManifest.xml
|   +-- src/test/my/modules/helloworld
|   |   +-- HelloWorldModuleTest.kt
|   +-- build.gradle
+-- ios
|   +-- EXHelloWorld
|   |   +-- EXHelloWorldModule.h
|   |   +-- EXHelloWorldModule.m
|   +-- EXHelloWorld.podspec
+-- src
|   +-- __tests__
|   |   +-- HelloWorld-test.ts
|   +-- HelloWorld.ts
|   +-- HelloWorld.types.ts
|   +-- ExpoHelloWorld.ts
|   +-- ExpoHelloWorld.web.ts
+-- package.json
+-- unimodule.json
+-- ... [other files: babel config, eslint config, tsconfig etc.]

Using older Expo CLI may result in that files under src are named differently. Files shown above starting with Expo prefix would be generated without that prefix, and HelloWorld.ts would be called Unimodule.ts. That's because CLI expected our module name to start with expo- prefix, e.g. expo-hello-world.

The structure is very simillar to all modules across Expo world. There are android and ios directories containing native source files, a src directory with TypeScript files, and a build folder with compiled JS. Worth noticing is also unimodule.json - this file defines, which platforms our module is targeting.

To build our newly-created module, just do:

cd hello-world && yarn

Now, our module is ready to be included into our application.

Configuring module manually

In this section I'll be doing the whole configuration from scratch. If you have already done it using my custom template, it's still worth reading to have better knowledge about how it works. Some part of this configuration, like jest, eslint, prettier is not necessary, but it's recommended, because we want our module to be clean, and every Expo module is using those tools.

Let's generate our module using default template:

cd MyBareApp/MyNativeModules
expo generate-module
cd hello-world

Answer the questions the same way as above. Generated module would also have the same structure. First thing is to add some dependencies:

yarn add -D expo-module-scripts eslint prettier

Pacage expo-module-scripts makes our life easier - it automates a few processes for us - it comes with CLI command that automates building, linting and testing. However, it's optional, you can still configure everything manually.

Since expo-module-scripts is rarerly published, it may have outdated dependencies. You may verify them using npm view expo-module-scripts and comparing to the latest master package.json. That's why I prefer installing them directly: yarn add --dev typescript ts-jest jest-expo eslint-config-universe.

In package.json we need to update the scripts section to use expo-module-scripts CLI. We should also add jest preset:

{
  "scripts": {
    "build": "expo-module build",
    "clean": "expo-module clean",
    "lint": "expo-module lint",
    "test": "expo-module test",
    "prepare": "expo-module prepare",
    "prepublishOnly": "expo-module prepublishOnly",
    "expo-module": "expo-module"
  },
  "jest": {
    "preset": "expo-module-scripts"
  }
  ...

Note for Windows users: expo-module-scripts uses bash scripts and thus does not work on Windows unless you run it using WSL. If you do not want to, you'll have to write scripts section manually in package.json.

A few words of explaination:

  • build script runs tsc --watch under the hood and compiles TS from src into JS inside build dir. It's good to have it running while editing your module TS code.
  • clean removes build directory
  • lint runs eslint under the hood to look for errors (it includes prettier checks)
  • test runs jest in watch mode. Runs tests for all platform presets.
  • prepare is part of package lifecycle and is ran automatically after yarn install. It runs clean and build internally.

For more information about those scripts, see its docs.

It's highly recommended (and required by current eslint preset) to use prettier. We need to create a .prettierrc file and fill it with example configuration:

{
  "printWidth": 100,
  "tabWidth": 2,
  "singleQuote": true,
  "jsxBracketSameLine": true,
  "trailingComma": "es5"
}

If you want to customize your linting configuration, worth mentioning is also eslint-config-universe which contains eslint and prettier presets used in Expo.

We're now ready to check if our scripts are working properly. To ensure everything is configured, we can also run expo-module configure, which generates missing config files:

npx expo-module configure
yarn clean
yarn build
# Ctrl+C to exit watch mode in build
yarn lint
yarn test
# Press Q to exit watch mode in tests

If there are no errors, we are ready to start writing code. There may be prettier warnings, you can fix them by running yarn lint --fix.

Undestranding the code structure

The module consists of three worlds: User-facing JS/TS, Android and iOS native. There's also web, but it's pretty straightforward - code is written directly in TypeScript.

TypeScript side

Remember to have yarn build running while editing module's TypeScript code!

Take a look at src/HelloWorld.ts. This is the main file, which by default exports module API to your apps. Currently, it has one demo method:

export async function someGreatMethodAsync(options: SampleOptions) {
  return await ExpoHelloWorld.someGreatMethodAsync(options);
}

It returns result of another function of the same name (of course it doesn't have to be the same name) which is imported from ExpoHelloWorld. There are two implementations of this module: ExpoHelloWorld.ts and ExpoHelloWorld.web.ts. First one is bundled when running on Android and iOS, and the latter one, when running Web. When we open ExpoHelloWorld.ts, we can see that it just reexports it from NativeModulesProxy - that means the native counterpart is called here. In contrast to the native one, Web version has direct TypeScript implementation.

Important note: When you import TypeScript module, e.g. ExpoHelloWorld, a bundler can inject different file depending on target platform it's bundling for:

  • ExpoHelloWorld.android.ts for Android
  • ExpoHelloWorld.ios.ts for iOS
  • ExpoHelloWorld.native.ts for Android or iOS if respective file above doesn't exist
  • ExpoHelloWorld.web.ts for Web
  • ExpoHelloWorld.ts if none of above exist

Let's rename and modify this function to better observe the results:

export async function myAwesomeMethodAsync(options: SampleOptions) {
  console.log('Calling someGreatMethodAsync()...');
  const msgFromNative = await ExpoHelloWorld.someGreatMethodAsync(options);
  return "Received: " + msgFromNative;
}

Native side

If you already have experience with React Native custom native modules (Android and iOS), this part should look familiar to you, because Expo API is very simillar to the one known from RN.

One new thing is Module Registry. It's an interface from @unimodules/core, which contains list of all registered unimodules in application. It allows you to access one module from another, e.g. Permissions from Camera, using interfaces.

Example: We have three modules: Camera, PermissionsInterface and Permissions, which implements that interface. When Camera needs to ask for user permissions, it asks Module Registry to find module implementing PermissionsInterface and then it can use that implementation.

iOS

The ios/EXHelloWorld directory consists of two files: EXHelloWorldModule.h and EXHelloWorldModule.m which are Objective-C implementation of our module. The .h file declares our module as @interface:

@interface EXHelloWorldModule : UMExportedModule <UMModuleRegistryConsumer>
@end

Note that it inherits from UMExportedModule - base class for all unimodules exported to JavaScript.

The EXHelloWorldModule.m contains implementation of our module. I've modified it a bit for the needs of this article:

@interface EXHelloWorldModule ()
@property (nonatomic, weak) UMModuleRegistry *moduleRegistry;
@end

@implementation EXHelloWorldModule

UM_EXPORT_MODULE(ExpoHelloWorld);

- (void)setModuleRegistry:(UMModuleRegistry *)moduleRegistry
{
  _moduleRegistry = moduleRegistry;
}

UM_EXPORT_METHOD_AS(someGreatMethodAsync,
                    options:(NSDictionary *)options
                    resolve:(UMPromiseResolveBlock)resolve
                    reject:(UMPromiseRejectBlock)reject)
{
  NSString* optionValue = [options objectForKey:@"someOption"];
  resolve([NSString stringWithFormat:@"Hello from iOS! Option value: %@", optionValue]);
}

@end

Notice two macros used here: UM_EXPORT_MODULE(), which marks our module as exportable, and UM_EXPORT_METHOD_AS() which defines methods to be exported to JavaScript.

Our someGreatMethodAsync is exported to JS (and there imported from NativeModulesProxy) exactly from this place. It passes our JS options parameter as NSDictionary*. The latter two parameters: resolve and reject are blocks to be called, when we want our JS method to return promise result.

Important: Each native method called from JS is async and it's native execution should either call resolve or reject! Otherwise our app can hang waiting for promise to respond.

Android

When opened android/src/main/java/my/modules/helloworld, there are two Kotlin files: HelloWorldModule.kt and HelloWorldPackage.java. The module file is just a Java/Kotlin class, which inherits from org.unimodules.core.ExportedModule. This is how it looks like, with slight modifications:

class HelloWorldModule(context: Context) : ExportedModule(context) {

  private var mModuleRegistry: ModuleRegistry? = null

  override fun getName(): String {
    return "ExpoHelloWorld"
  }

  override fun onCreate(moduleRegistry: ModuleRegistry) {
    mModuleRegistry = moduleRegistry
  }

  @ExpoMethod
  fun someGreatMethodAsync(options: Map<String, Any>, promise: Promise) {
    val optionValue = options.getOrDefault("someOption", "unspecified")
    promise.resolve("Hello from Android! Option value: $optionValue")
  }
}

As you can see, our API method is marked here as @ExpoMethod which means it will be exported to the JS code. We also have getName() which is the only mandatory function that needs to be overriden. The string returned is a module name - the one which we refer to in JS, importing from NativeModulesProxy.

Although Kotlin is recommended, you can still use Java if you prefer to.

Importing module to our app

The simplest way would be to add local directory as dependency to our App's package.json and run yarn install. And that's awesome, but we'd have to do it every time we modify our module. Let's use a different approach:

Yarn workspaces

You can find more info here, I'll just show how to do it quickly in our case.

Open your app's MyBareApp/package.json and apply the following:

{
  "private": true,
  "workspaces": ["MyNativeModules/*"],
  "dependencies": {
      "hello-world": "*"
  }
}

Save the file and run yarn. Now your MyBareApp/node_modules/hello-world is a symlink to a MyBareApp/MyNativeModules/hello-world. This means that each change done in your module is automatically reflected in your app.

Yarn link / NPM link

You can find more information about Yarn links here. I'll just show the quick way

cd MyBareApp/MyNativeModules/hello-world
yarn link
cd ../../
yarn link hello-world
yarn install

Now you achieved the same result as in case of using Yarn workspaces. This approach has one advantage - your unimodule package can be anywhere on your disk, it doesn't have to be relative to your App project.

Use it in your code

Open any component in your app and try your module out. I'll show the minimal example:

import React from 'react';
import { StyleSheet, Text, View, Button } from 'react-native';

import * as HelloWorld from 'hello-world';

export default function App() {

  const [hello, setHello] = React.useState('None yet');

  const loadHelloMessage = async () => {
    const helloMsg = await HelloWorld.myAwesomeMethodAsync({
      someOption: '🚀'
    });
    setHello(helloMsg);
  }
  
  return (
    <View style={styles.container}>
      <Text>Click to show message</Text>
      <Button onPress={loadHelloMessage} title="Press me" />
      <Text>Message: {hello}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

Now run your app:

# Android
npx react-native run-android

# iOS
npx pod-install
npx react-native run-ios

Watch installation process - the hello-world module should appear somewhere on the unimodule list. How this happens? That's simple - build script (CocoaPods or Gradle) crawls node_modules (or others if specified explicitly) looking for packages containing unimodule.json. When one is found, it is attached to the project. Then (TODO what)

When your app is running, press the button and see response coming from your native code.

Further module usage

Your new module is easily reusable. Everything you have to do is:

  • Create your app, or use existing.
  • For non-expo apps: install unimodules.
  • Import your module by either:
    • Using yarn workspaces
    • Using npm/yarn linking
    • Just installing from NPM (if you've published your module)
    • Adding dependency as local path in your package.json
  • (on iOS) Run npx pod-install
  • Run your app!

It's THAT simple! No modifications in native code are required. This simple process works for any module from Expo, since all of them are Unimodules.

I also encourage you to explore existing modules' source code and see how they are done.

Troubleshooting

TODO: Describe problems encountered below:

  • My TypeScript files generated Unimodule.ts instead of real name - Fix: update Expo CLI expo/expo-cli#2548
  • After changing my Kotlin source code, I got an error Duplicate class XXX in my module. - Fix is coming (expo/expo#10007). Update react-native-unimodules when it's published.
  • Android error :unimodules-test-core not found or sth like that - FIX is going to be discussed - that package is not published yet. I can publish my own fork. But you can comment out this dependency in build.gradle but unit tests won't work.
  • My NPM/yarn scripts don't work on Windows (expo-module-scripts crashes) - Use WSL or replace expo-module-scripts by calling tsc, eslint, jest directly. Take a look at eslint-config-universe for nice presets.
  • My module is not building because of missing react and react-native dependencies - It won't build. You either use it inside react-based project or yarn workspace having that project, or add these dependencies to package.json.

Draft for Parts 2 and 3

Part 2: A bit more real-world example.

Now when know how everything works, we can do a tiny bit more sophisticated demo - a simple message box/alert. (??? or something else?)

  • Show events and constants
  • Show ViewManager etc
  • ReadableArguments i Bundle
  • Other UM Core functionalities?
  • Unit testing

Part 3: Advanced

Inter-module ops

  • Using singleton modules
  • Creating and using module interfaces
  • Adapters (rn-adapter)
  • Detailed architecture of each modules
  • ???

Also: Swift in custom modules? check

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment