AccueilClientsExpertisesBlogOpen SourceJobsContact

24 octobre 2023

Intégration de module natif avec Expo Modules

9 minutes de lecture

Intégration de module natif avec Expo Modules

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 fonction requireNativeModule que l'on peut retrouver dans src/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 avec Property 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 à un EventEmitter
  • 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 avec Function("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.

18 avenue Parmentier
75011 Paris
+33 1 43 57 39 11
hello@premieroctet.com

Suivez nos aventures

GitHub
Twitter
Flux RSS

Naviguez à vue