12th July, 2023. I'm going to try creating an iOS app called Paranovel, using Expo. My environment for mobile app dev (Xcode, Ruby, etc.) should be in reasonably good shape already as I frequently develop with React Native and NativeScript.
Go to https://docs.expo.dev, and see the Quick Start: npx create-expo-app paranovel
This runs with no problem, then I get this macOS system popup:
“Terminal” wants access to control “System Events”. Allowing control will provide access to documents and data in “System Events”, and to perform actions within that app.
I allowed it and the process succeeded with:
To run your project, navigate to the directory and run one of the following npm commands.
- cd paranovel
- npm run android
- npm run ios
- npm run web
I ran cd paranovel && npm run ios
. Next thing I noticed, my simulator was asking:
Open in "Expo Go"?
... Then the Expo Go app greets me with a modal:
Hello there, friend!
Since this is your first time opening Expo Go, we wanted to show you this menu and let you know that in a simulator you can press ^⌘Z to get back to it at any time.
I dismissed the message, and looked back at my terminal:
npm run ios
> [email protected] ios
> expo start --ios
Starting project at /Users/jamie/Documents/git/paranovel
Starting Metro Bundler
› Opening exp://192.168.11.3:19000 on iPhone 14 Pro
Downloading the Expo Go app [========================================] 100% 0.0
[QR code]
› Metro waiting on exp://192.168.11.3:19000
› Scan the QR code above with Expo Go (Android) or the Camera app (iOS)
› Press a │ open Android
› Press i │ open iOS simulator
› Press w │ open web
› Press j │ open debugger
› Press r │ reload app
› Press m │ toggle menu
› Press ? │ show all commands
Logs for your project will appear below. Press Ctrl+C to exit.
› Opening the iOS simulator, this might take a moment.
iOS Bundling complete 4518ms
WARN Constants.platform.ios.model has been deprecated in favor of expo-device's Device.modelName property. This API will be removed in SDK 45.
I figured my app was running through the Expo Go client, so I needed to dismiss the client overlay. Doing that, I now see a Hello World with a warning toast for the aforementioned deprecation notice, but also the words:
Open up App.js to start working on your app!
My heart sinks as I realise it's gone and created a JS template rather than a TS one (in my defence, it never asked me what I wanted!). I thought the default in React Native was TS now, so it's strange that Expo haven't made the jump.
I kill the terminal process, delete paranovel
and start all over again. So much for "quick start".
I try npx create-expo-app --help
and see mentions of a --template
flag, but those consist of only blank, tabs, bare-minimum
.
I check through the docs:
- https://docs.expo.dev/get-started/create-a-project/
- https://docs.expo.dev/develop/project-structure/
... no mention of TypeScript.
I use the search bar and finally find an article, https://docs.expo.dev/guides/typescript/, which directs me to use: npx create-expo-app -t expo-template-blank-typescript
.
I'll specify my project name while I'm at it, though:
npx create-expo-app -t expo-template-blank-typescript paranovel
cd paranovel
npm run ios
Bearing in mind that the Expo Go client is still running and we're making an app with exactly the same name again, let's see how it handles.
It brings focus to the simulator, but that's about it. The Hello World still refers to App.js
. Fine, let's meet it halfway.
I press ^⌘Z
and press the Reload button. That fixed it! The starting text now refers to App.tsx
and the annoying warning toast is back.
I save an edit to App.tsx and it hot-reloads, dismissing the toast. Oh, good.
Let's see what we've got (with tree -a -L 1
):
.
├── .expo
├── .git
├── .gitignore
├── App.tsx
├── app.json
├── assets
├── babel.config.js
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json
That's actually way nicer than I'd expected. Very few config files to inspect, and most are familiar from web dev. A*!
Now let's look for skeletons. app.json
... 30 lines long, cool. and .expo
... not much to see in there, just cache stuff. I really like the .expo/README.md
explaining what it's all for.
I have some existing native code that I want to use for this. I'm perfectly familiar with the Expo SDK and know that the code I want to integrate is something that doesn't exist already and has to be done natively, so let's see how to go about that.
First up, where does my native code live?
... No ios
or build
folder, okay. Off to the docs.
I quickly find the Add custom native code article:
The tradeoff is that Expo Go does not allow you to add custom native code, you can only use native modules built into the Expo SDK.
Okay, that sounds familiar. If you want to use stuff outside of the Expo SDK, it used to be that you'd expo eject
but I'm aware that the procedure has changed since then.
It seems I have a choice between:
- Adding custom native code with development builds
- Generate native projects with prebuild
I'll have to read up on them to understand the tradeoffs. I check every article under the Development Builds header. I scrub through the videos just in case, but I find no mention whatsoever of how to add your own native code. They've just lost a customer.
Let's try this option instead.
If you would like to move from a JavaScript based project and take ownership over the iOS and Android native projects, you can generate them by running
npx expo prebuild
, ornpx expo run:[ios|android]
(which will run prebuild automatically).
Okay, I'll kill the current terminal process and try npx expo run:ios
:
npx expo run:ios
📝 iOS Bundle Identifier Learn more: https://expo.fyi/bundle-identifier
✔ What would you like your iOS bundle identifier to be? … uk.co.birchlabs.paranovel
✔ Created native project | gitignore skipped
✔ Added Metro config
✔ Updated package.json and added index.js entry point for iOS and Android
› Removed "main": "node_modules/expo/AppEntry.js" from package.json because we recommend using index.js as main instead
› Installing using npm
> npm install
[truncated]
✔ Config synced
✔ Installed pods and initialized Xcode workspace.
[truncated]
› Build Succeeded
› 0 error(s), and 4 warning(s)
Starting Metro Bundler
[QR code]
› Metro waiting on
uk.co.birchlabs.paranovel://expo-development-client/?url=http%3A%2F%2F192.168.11.3%3A8081
› Scan the QR code above with Expo Go (Android) or the Camera app (iOS)
› Press a │ open Android
› Press i │ open iOS simulator
› Press w │ open web
› Press j │ open debugger
› Press r │ reload app
› Press m │ toggle menu
› Press ? │ show all commands
› Installing on iPhone 14 Pro
› Opening on iPhone 14 Pro (uk.co.birchlabs.paranovel)
› Opening uk.co.birchlabs.paranovel://expo-development-client/?url=http%3A%2F%2F192.168.11.3%3A8081 on iPhone 14 Pro
› Logs for your project will appear below. Press Ctrl+C to exit.
iOS Bundling complete 1190ms
WARN Constants.platform.ios.model has been deprecated in favor of expo-device's Device.modelName property. This API will be removed in SDK 45.
Interestingly, now that the deeplink opened is no longer exp://
but uk.co.birchlabs.paranovel://
, it asks:
Open in "paranovel"?
... indicating that it is my app, and not the Expo Go Client, that takes care of this link. So yes, we're no longer a subordinate of the Expo Go Client.
This time, when I save a code change, hot-reload works. Nice.
Let's see what's changed as a result of npx expo run:ios
:
.
├── .expo
├── .git
├── .gitignore
├── App.tsx*
├── app.json*
├── assets
├── babel.config.js
+├── index.js
+├── ios
+├── metro.config.js
├── node_modules*
├── package-lock.json*
├── package.json*
└── tsconfig.json
* modified
Cool, we have the familiar ios
folder back. Curiously, comparing the mentions of:
› Compiling expo-modules-core Pods/ExpoModulesCore » ExpoBridgeModule.m
... in the Xcode build logs between our initial build and this new build, I don't see an obvious difference in Expo modules stripped out this time round. I look inside ios/Podfile.lock
and see:
- EXApplication (5.1.1):
- ExpoModulesCore
- EXConstants (14.2.1):
- ExpoModulesCore
- EXFileSystem (15.2.2):
- ExpoModulesCore
- EXFont (11.1.1):
- ExpoModulesCore
- Expo (48.0.20):
- ExpoModulesCore
- ExpoKeepAwake (12.0.1):
- ExpoModulesCore
- ExpoModulesCore (1.2.7):
- React-Core
- React-RCTAppDelegate
- ReactCommon/turbomodule/core
- EXSplashScreen (0.18.2):
- ExpoModulesCore
- React-Core
These all sound pretty reasonable. No QR code scanner or anything floating around in there.
There was mention of Expo Config Plugins earlier, but I think what I want to make is an Expo Module.
Following: https://docs.expo.dev/modules/get-started/
npx create-expo-module@latest --local
Need to install the following packages:
[email protected]
Ok to proceed? (y) y
The local module will be created in the modules directory in the root of your project. Learn more: https://expo.fyi/expo-module-local-autolinking.md
✔ What is the name of the local module? … mecab
✔ What is the native module name? … ExpoMecab
✔ What is the Android package name? … expo.modules.mecab
✔ Downloaded module template from npm
✔ Created the module from template files
✅ Successfully created Expo module in modules/mecab
I'm not totally sure what it means by "local module" vs. "native module", especially as it's going to be a thin wrapper around a Cocoapod, but I decided just to use names that won't clash with the classes I'm going to import.
I decided to try running this without first killing the Metro bundler, and it went fine. It produced this new modules
folder in the root of my project:
It
modules
└── mecab
├── android
│ ├── build.gradle
│ └── src
│ └── main
│ ├── AndroidManifest.xml
│ └── java
│ └── expo
│ └── modules
│ └── mecab
│ ├── ExpoMecabModule.kt
│ └── ExpoMecabView.kt
├── expo-module.config.json
├── index.ts
├── ios
│ ├── ExpoMecab.podspec
│ ├── ExpoMecabModule.swift
│ └── ExpoMecabView.swift
└── src
├── ExpoMecab.types.ts
├── ExpoMecabModule.ts
├── ExpoMecabModule.web.ts
├── ExpoMecabView.tsx
└── ExpoMecabView.web.tsx
It's given me more than I need (this app is strictly for iOS, and I'm not building a view component), so I can remove a few of these:
modules
└── mecab
├── expo-module.config.json
├── index.ts
├── ios
│ ├── ExpoMecab.podspec
│ └── ExpoMecabModule.swift
└── src
└── ExpoMecabModule.ts
My Expo Module is going to depend upon a CocoaPod, so it's nice to see it brings its own podspec. I'll add a dependency to that.
Of note, it's only a podspec, not a Podfile, so can only refer to versioned pods and not local sources. Fortunately I can work with versioned pods, so I'll edit the module podspec like so:
# modules/mecab/ios/ExpoMecab.podspec
Pod::Spec.new do |s|
s.name = 'ExpoMecab'
s.version = '1.0.0'
s.summary = 'A sample project summary'
s.description = 'A sample project description'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platform = :ios, '13.0'
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
+ s.dependency 'mecab-ko'
+ s.dependency 'mecab-naist-jdic-utf-8'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
I had thought I'd need to add pod 'mecab-ko'
and pod 'mecab-naist-jdic-utf-8'
to my project's Podfile, but later found it to be unnecessary.
Side-note: It's annoying that I've already got these particular Cocoapods wrapped as a React Native native module (an npm package), but there's just no documentation on how to wrap React Native native modules using the Expo Module API, so I'm going to have to redo a little bit of work later (like exposing the Obj-C/Swift APIs to JS).
Now, as per the Get started walkthrough, we'll reinstall the pods.
pod install --project-directory=ios
Using Expo modules
[Expo] Enabling modular headers for pod ExpoModulesCore
[Expo] Enabling modular headers for pod mecab-ko
[Expo] Enabling modular headers for pod
mecab-naist-jdic-utf-8
Framework build type is static library
[Codegen] Generating ios/build/generated/ios/React-Codegen.podspec.json
Analyzing dependencies
[Codegen] Found FBReactNativeSpec
Downloading dependencies
Installing ExpoMecab (1.0.0)
Installing mecab-ko (0.4.1)
Installing mecab-naist-jdic-utf-8 (0.1.3)
Generating Pods project
Setting REACT_NATIVE build settings
Setting CLANG_CXX_LANGUAGE_STANDARD to c++17 on /Users/jamie/Documents/git/paranovel/ios/paranovel.xcodeproj
Pod install took 6 [s] to run
Integrating client project
expo_patch_react_imports! took 0.0913 seconds to transform files.
Pod installation complete! There are 45 dependencies from the Podfile and 47 total pods installed.
Good stuff, it clearly linked our module. And I didn't have to kill the Metro bundler, either.
I felt a little blind writing Swift code without IDE support, so I opened ios/paranovel.xcworkspace
to edit modules/mecab/ios/ExpoMecabModule.swift
from inside Xcode.
I got the hang of the Expo Modules API very quickly. Very good design (especially considering how well it tames the React Native native modules API). I'm curious whether it supports the Swift Bool?
type, as optional booleans aren't supported in Objective-C yet are a common thing to express in TypeScript. Here's what I whipped up in modules/mecab/ios/ExpoMecabModule.swift
:
import ExpoModulesCore
import mecab_ko
public class ExpoMecabModule: Module {
private static let locale = Locale(identifier: "ja") as CFLocale
private static let jpBundlePath = Bundle.main.path(
forResource: DEFAULT_JAPANESE_RESOURCES_BUNDLE_NAME,
ofType: "bundle"
)
private static let jpBundleResourcePath = Bundle.init(path: jpBundlePath!)!.resourcePath
private static let mecabJapanese: Mecab = Mecab.init(dicDirPath: jpBundleResourcePath!)
public func definition() -> ModuleDefinition {
Name("ExpoMecab")
Function("tokenize") { (text: String) -> [Token] in
let nodes: [MecabNode] = ExpoMecabModule.mecabJapanese.parseToNode(
with: text,
calculateTrailingWhitespace: true
) ?? Array<MecabNode>()
return nodes.map {
Token(
surface: Field(wrappedValue: $0.surface),
pronunciation: Field(wrappedValue: $0.features?[safe: 8]),
lemma: Field(wrappedValue: $0.features?[safe: 6])
)
}
}
}
}
struct Token: Record {
@Field
var surface: String
@Field
var pronunciation: String?
@Field
var lemma: String?
}
// TODO: move to main project or own module
extension Array {
subscript(safe index: Int) -> Element? {
guard indices.contains(index) else {
return nil
}
return self[index]
}
}
I was able to edit this native code and run the target in Xcode, and it seamlessly restarted the app in the simulator without causing the Metro Bundler to complain. Really nice, though not technically a new DX feature – I think this has been possible since React Native started sharing the same build cache as the Xcode project on iOS a few years ago.
I updated my corresponding TS code in modules/mecab/index.ts
to:
import ExpoMecabModule from './src/ExpoMecabModule';
export function tokenize(text: string): Token[] {
return ExpoMecabModule.tokenize(text);
}
export interface Token {
surface: string;
lemma: string | null;
pronunciation: string | null;
}
Calling my native module was as simple as importing it from ./modules/mecab
:
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
import { tokenize } from './modules/mecab';
export default function App() {
// Yes, this belongs in a useEffect block, but just to try it out:
console.log('tokenize', tokenize('ご覧のスポンサーの提供でお送りします'));
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
}
This produced the following output:
[
{ "surface": "ご覧", "lemma": "ご覧", "pronunciation": "ゴラン" },
{ "surface": "の", "lemma": "の", "pronunciation": "ノ" },
{ "surface": "スポンサー", "lemma": "スポンサー", "pronunciation": "スポンサー" },
{ "surface": "の", "lemma": "の", "pronunciation": "ノ" },
{ "surface": "提供", "lemma": "提供", "pronunciation": "テイキョー" },
{ "surface": "で", "lemma": "で", "pronunciation": "デ" },
{ "surface": "お送り", "lemma": "お送り", "pronunciation": "オオクリ" },
{ "surface": "し", "lemma": "する", "pronunciation": "シ" },
{ "surface": "ます", "lemma": "ます", "pronunciation": "マス" }
]
Creating a new Expo project for the first time in a year or two, I felt rather lost at times. As Expo has a very high surface area, it's easy to get lost in docs. But still, my previous experience using Expo and React Native in the past did save me a lot of wrong turns, so I'd expect first-time users to have a harder experience still. That all said, I was pleasantly surprised to not run into any red screens nor meet any mysterious CLI complaints. Expo and React Native have certainly come a long way since the dark old days.
Next, I must say I have nothing but praise for Expo Modules. For once, writing native modules felt like a calm and fun experience focused on the logic I wanted to write, rather than a trial of walking through rose bushes trying to set up fragile boilerplate. I think Expo have finally tamed the very prickly React Native native modules APIs and the fact that they've brought most of the benefits of JSI to Swift as well is truly commendable.
All considered, I'm confident to continue building Paranovel as an Expo app. Keep it up, everyone who helped streamline the developer experience to this degree!