24 octobre 2023
Intégration de module natif avec Expo Modules
9 minutes de lecture
En l'espace de presque deux ans, la popularité d'Expo a quintuplé, l'amenant au rang d'incontournable dans l'écosystème react-native. La quantité de modules natifs intégrés, la souplesse proposée pour l'ajout de librairies tierces ainsi qu'un système de build sur le cloud expliquent sa notoriété croissante. Grâce aux clients de développement personnalisés, il est maintenant possible d'avoir sa propre version d'Expo Go incluant ses propres modules natifs.
Toutefois, il peut arriver que dans le cadre d'un projet, l'installation d'une librairie native tierce soit nécessaire, que ce soit pour une vue ou pour un ensemble de fonctionnalités. Il peut également arriver que cette librairie ne propose aucune implémentation compatible avec react-native. Dans ce cas là, il est nécessaire de mettre les mains dans le cambouis et d'intégrer le module natif soi-même.
Dans cet article nous allons aborder l'intégration d'une vue et d'un ensemble de fonctionnalités natives à l'aide de l'API Expo Modules.
Pourquoi Expo Modules ?
A l'heure actuelle, il est assez compliqué de créer des modules natifs utilisant l'API de react-native. En effet sur la documentation officielle, on peut y trouver un tutoriel pour l'implémentation d'un module. Mais il n'existe pas vraiment de documentation à proprement parler concernant les différentes méthodes disponibles.
De plus avec l'arrivée de la nouvelle architecture, la création de modules natifs se complexifie. D'une part, Fabric (nouveau système de rendu des vues natives) implique pour les mainteneurs de librairies de gérer à la fois l'ancien et le nouveau système de rendu. D'autre part, une partie du code de la nouvelle architecture doit être écrite en C++, en plus des langages propres aux plateformes.
Pour palier à cela, Expo Modules propose une API nous permettant de ne pas se soucier de toutes ces problématiques. Les différentes méthodes exposées s'adapteront automatiquement à la nouvelle architecture selon si elle est activée ou non. De plus, il est possible d'utiliser des langages modernes adaptés aux plateformes respectives, ici Swift pour iOS et Kotlin pour Android.
Initialisation
Pour créer notre module, nous allons initialiser un projet Expo Modules :
npx create-expo-module@latest
La commande demandera de saisir un nom de librairie et un nom de module. Nous le nommerons MyExpoModule
pour cet article. Un dossier est créé avec l'arborescence suivante :
ios
: le code source de notre module iOS. Il sera nécessaire d'ouvrir un projet sous XCode avec notre module en dépendance pour y éditer les fichiers.android
: le code source de notre module Android. Il sera nécessaire d'ouvrir un projet sous Android Studio avec notre module en dépendance pour y éditer les fichiers.src
: le code source côté JavaScript de notre module. C'est ici que nous allons écrire les différentes fonctions exposées à notre application react-native qui feront le pont entre le code JavaScript et le code natif.example
: un projet react-native Expo permettant de tester nos modifications et d'éditer nos fichiers natifs à travers XCode et Android Studio.
Il est possible de générer un module Expo pour une utilisation locale au sein d'un projet. Pour cela on passera le paramètre --local
à notre commande d'initialisation. Dans ce cas, un dossier modules
sera créé à la racine du projet, contenant la même arborescence que celle décrite ci-dessus, sauf pour le dossier example
, qui aura pour équivalent le projet où l'on installe notre module.
La commande nous a généré un module contenant une vue, des fonctions et des propriétés associées. Nous allons pour le moment nous concentrer sur le contenu généré par défaut sans rien modifier.
Contenu de fichiers natifs
Explorons maintenant ce que chaque dossier natif contient.
Commençons par le fichier MyExpoModule.swift
. C'est la déclaration de notre module. On se basera ici sur le fichier iOS, mais côté Android on retrouvera les mêmes informations, les différences étant liées au langage utilisé.
On peut y retrouver une classe contenant une fonction definition
. C'est ici que nous déclarerons toutes les méthodes, les propriétés et la vue associée à notre module.
- Name("MyExpoModule") correspond à la déclaration du nom de notre module, ici
MyExpoModule
. C'est via ce nom que nous récupérerons notre module côté JavaScript grâce à la fonctionrequireNativeModule
que l'on peut retrouver danssrc/MyExpoModule.ts
. Constants(["PI": Double.pi])
correspond à un ensemble de propriété accessibles directement via le module JavaScript. Ces propriétés ne sont pas modifiables. Il existe un équivalent avecProperty
qui permet de lire et modifier une propriété.Events("onChange")
correspond à un événement qui peut être émit depuis le module. Cet événement pourra être écouté côté JavaScript grâce à unEventEmitter
AsyncFunction("setValueAsync")
correspond à une fonction asynchrone accessible côté JavaScript. On se servira de cette méthode pour définir des fonctions dont le temps d'exécution peut potentiellement être long et donc bloquant pour le thread JavaScript. On peut retrouver un équivalent avecFunction("hello")
pour des fonctions synchrones, à utiliser dans des situations où les temps d'exécutions sont courts et non bloquants.View(MyExpoModuleView.self)
correspond à la déclaration de la vue associée au module. Il n'est à l'heure actuelle pas possible de définir plus d'une vue pour un module. De la même manière que pour les définitions du module, il est possible de passer un ensemble de définitions associées à notre vue. En l'occurence ici, on retrouvera les props et les événements sous forme de callback.Prop("name")
correspond donc à la déclaration d'une prop que l'on peut passer à notre composant React. Cette méthode prend en paramètre une closure (ou lambda pour du Kotlin) avec en paramètre l'instance de la vue ainsi que la propriété passée. Attention: cette méthode ne sera pas exécutée si les type associés à la prop ne correspondent pas entre le côté natif et le côté JavaScript.
Maintenant regardons le contenu de MyExpoModule.swift
. Encore une fois, on se basera sur le fichier iOS, la partie Android étant similaire.
On retrouve une classe MyExpoModuleView
qui hérite de ExpoView
. Cette vue est en fait un équivalent du composant View
de react-native. Par défaut donc, cette vue n'a aucune dimension, ça sera à nous d'en donner une via la prop style
côté React.
Modification de la vue native
Implémentons une WebView dans notre vue native.
Rappel : il existe deux scripts pour ouvrir le projet d'exemple sur les plateformes natives: yarn open:ios
et yarn open:android
. Pour l'édition des fichiers iOS dans XCode, il faut se rendre dans Pods > Development Pods > MyExpoModule. Pour Android, il faut se rendre dans my-expo-module-example > my-expo-module.
iOS :
import ExpoModulesCore
import WebKit
class MyExpoModuleView: ExpoView {
// Initialisation d'une variable de classe pour notre WebView
var webview: WKWebView?
/**
* Initialisation d'une variable de classe pour une url
* que l'on passera en prop
*/
var url: URL? {
// Mise à jour de la WebView quand l'url a changé
didSet {
if (url != nil) {
webview?.load(URLRequest(url: url!))
}
}
}
required init(appContext: AppContext?) {
super.init(appContext: appContext)
// Initialisation de notre WebView
webview = WKWebView()
/**
* On indique à notre vue que les vues enfants doivent être rognées
* si leur dimension dépasse la dimension de la vue
*/
clipsToBounds = true
/**
* On ajout la WebView à notre vue
*/
addSubview(webview!)
if (url != nil) {
webview?.load(URLRequest(url: url!))
}
}
/**
* A chaque mise à jour du layout, on met à jour
* la dimension de notre WebView pour qu'elle corresponde
* aux dimensions de notre vue.
*/
override func layoutSubviews() {
webview?.frame = bounds
}
}
Android :
package expo.modules.myexpomodule
import android.content.Context
import android.webkit.WebView
import android.webkit.WebViewClient
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
import java.net.URL
class MyExpoModuleView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
var url: URL? = null
set(value) {
field = value
/**
* Quand la valeur change, on charge la nouvelle url
* dans la WebView
*/
if (value != null && webView != null) {
webView.loadUrl(value.toString())
}
}
/**
* Variable de classe initialisée à l'instantiation de la classe
*/
internal val webView = WebView(context).also {
/**
* On indique que la vue doit prendre la longueur et la hauteur
* de la vue parente
*/
it.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
it.webViewClient = object : WebViewClient() {}
/**
* On ajoute notre WebView à la vue parente
*/
addView(it)
if (url != null) {
it.loadUrl(url.toString())
}
}
}
Maintenant mettons à jour notre module pour que la vue puisse recevoir une prop url
.
iOS :
View(MyExpoModuleView.self) {
Prop("url") { (view: MyExpoModuleView, url: URL?) in
view.url = url
}
}
Android :
View(MyExpoModuleView::class) {
Prop("url") { view: MyExpoModuleView, url: URL? ->
view.url = url
}
}
On peut maintenant modifier notre fichier example/App.tsx
:
import * as MyExpoModule from 'my-expo-module'
import { StyleSheet, View } from 'react-native'
export default function App() {
return (
<View style={styles.container}>
<MyExpoModule.MyExpoModuleView style={{ flex: 1 }} url="https://premieroctet.com" />
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
})
On compile notre application et voilà, notre WebView apparaît avec le super site de PremierOctet ! On peut tenter de modifier notre prop url
et la page se mettra à jour correctement.
Ajout d'événement liés à la vue
Maintenant que notre vue est fonctionnelle, on va ajouter un événement qui sera émis depuis la vue native lors du chargement d'une page.
Côté iOS, on utilisera le système de delegate
lié à la WebView. Côté Android, on utilisera notre objet WebViewClient
qui nous permet de nous brancher à un ensemble d'événements.
iOS :
View(MyExpoModuleView.self) {
// On déclare un évément onLoad qui pourra être passé en prop
Events("onLoad")
Prop("url") { (view: MyExpoModuleView, url: URL?) in
view.url = url
}
}
import ExpoModulesCore
import WebKit
class MyExpoModuleView: ExpoView, WKNavigationDelegate {
var webview: WKWebView?
var url: URL? {
didSet {
if (url != nil) {
webview?.load(URLRequest(url: url!))
}
}
}
let onLoad = EventDispatcher()
required init(appContext: AppContext?) {
super.init(appContext: appContext)
webview = WKWebView()
webview!.navigationDelegate = self
clipsToBounds = true
addSubview(webview!)
if (url != nil) {
webview?.load(URLRequest(url: url!))
}
}
override func layoutSubviews() {
webview?.frame = bounds
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if let url = webView.url {
onLoad([
"url": url.absoluteString
])
}
}
}
Android :
package expo.modules.myexpomodule
import android.content.Context
import android.webkit.WebView
import android.webkit.WebViewClient
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
import expo.modules.kotlin.viewevent.EventDispatcher
import java.net.URL
class MyExpoModuleView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
var url: URL? = null
set(value) {
field = value
if (value != null && webView != null) {
webView.loadUrl(value.toString())
}
}
private val onLoad by EventDispatcher()
internal val webView = WebView(context).also {
it.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
it.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String) {
onLoad(mapOf("url" to url))
}
}
addView(it)
if (url != null) {
it.loadUrl(url.toString())
}
}
}
On ajoute maintenant la prop onLoad
à notre composant :
<MyExpoModule.MyExpoModuleView
style={{ flex: 1 }}
url="https://premieroctet.com"
onLoad={(e) => console.log(e.nativeEvent)}
/>
On recompile notre application et voilà ! On peut voir un joli log dans notre terminal avec l'url de la page chargée.
En conclusion
La création d'une module natif et d'une vue qui y est associée est facilitée par l'API Expo Modules. Sa documentation assez fournie est un énorme plus par rapport à l'API de react-native. De plus, la compatibilité avec la nouvelle architecture nous simplifie grandement la tâche en tant que mainteneurs de librairie.
Pour ma part, cet article suit la publication d'une librairie utilisant Expo Modules, react-native-wallet, grâce à laquelle j'ai pu aussi commencer à me familiariser avec Swift et Kotlin, qui sont bien plus agréables à utiliser que l'Objective-C et le Java. Il est toutefois impératif d'avoir la librairie expo-modules-core
installée sur son projet. Ce qui peut être un inconvénient sur un projet non Expo où, bien souvent, on cherche à ne pas introduire de contenu lié à Expo. Mis à part ça, je ne peux que recommander l'usage de Expo Modules.