基于 Expo 的 App 开发小计

1,842 阅读12分钟

Expo 是一个 React Native 的框架( or 工具库), 在 RN 的基础上, Expo 提供了优秀的开发, 包括且不限于大量的 SDK(支持 Android/iOS/Web), 云端自动化构建, app 开发远程调试, js 代码热更新...

前言

时隔一年再次上手 RN 开发, 除了期待已久的 Hermes 引擎, 但是 RN 给我的开发体验并没有明显的提升,而且整个生态似乎也陷入了停滞, 许多流行的库都已许久未曾更新, 在这种环境下最值得唏嘘的恐怕就是 RN 宣传语 Learn once, write anywhere, 对笔者来说前端调试最好的环境还是 Chrome, 借着 RN for web, 可以将 RN 运行在浏览器上, 从而方便调试像素, 网络请求, debugger 代码, 本地存储.... 但是正如前面所说, 陷入停滞的 RN 生态却难以满足它的野心, 作为开发者, 想要在浏览器调试, IOS/Android 中运行是一件很不容易的事情, 随着运行环境的增加, 技术选型也必须非常谨慎, 下面是笔者最终的技术选型, 以及一些兼容性问题的解决.

依赖列表

兼容性问题

Webview 优化

更新与 2022年06月29日

在笔者的开发过程中, 基本都是使用 react-native-web 进行调试开发, 这样可以利用 web 生态的提升开发体验, 但是 react-native-webview(简称 RNW)是不兼容 react-native-web的, 所以需要特殊处理下, 这里笔者使用 react-native-web-webview 来代替 RNW, 这个库提供了与 RNW 相同的 api. 参考其 Readme, 替换 webpack 别名即可.

Can't resolve 'react-native-web/dist/exports/ViewPropTypes'

解决办法: github.com/necolas/rea… 这个方法在每次安装依赖后都去修改 node_modes/react-native-web/dist/index.js 的源码从而导出一个 ViewPropTypes 对象, 因为 ViewPropTypes 是仅用于定义 props 类型校验的对象, 所以为空即可.


截止2022年05月05日, 新版本已不再需要此 hack.

react-native-reanimated 没有 global.performance.now 方法

同样使用 patch-package 进行修改. stackoverflow.com/questions/7…


截止 2022年05月05日, 最新版已不再需要此 hack 方法.

加密货币库使用的相关依赖

加密货币库使用了大量的 nodejs 函数, 这些函数在新的 js 打包方案中已经被移除了, 所以开发者必须去提供其所需的运行环境, 笔者所做的处理如下:

yarn add node-libs-expo -D
yarn add react-native-get-random-values -D
yarn add react-native-crypto -D

metro.config.js

const { getDefaultConfig } = require('@expo/metro-config');
// 先获取 expo 的默认配置项, 否则会报错
const defaultConfig = getDefaultConfig(__dirname);
defaultConfig.resolver.extraNodeModules = require("node-libs-expo")

module.exports = defaultConfig;

~~shim.js (在 app.js 入口处第一个导入进来), ~~截止2022年05月05日, node-libs-expo 已经处理了下面代码的操作, 所以不再需要.

import 'react-native-get-random-values'

const isWeb = typeof window != 'undefined'
if (!isWeb) {
  require('node-libs-expo/globals')
  const { encode, decode } = require('base-64')
  global.atob = decode
  global.btoa = encode

// Needed so that 'stream-http' chooses the right default protocol.
  global.location = {
    protocol: 'file:',
  }
  require("react-native-crypto")

  global.navigator.userAgent = 'React Native'
}

global.process = require('process')
global.process.slice = (start, end) => Array.prototype.slice(start, end)
global.Buffer = require('buffer').Buffer

弹窗内第一次点击事件无效

这个问题笔者一度认为是手滑没有点到, 直到一次偶然才发现第一次点击必定失效, 于是笔者开始寻求最小复现方式, 最后终于发现仅在弹窗(Modal)中是可以触发的, 但是一旦放在页面中就无法打开, 于是继续删除页面的多余内容, 最后只剩最外层的 Scrollview 后终于发现了稳定的复现方式, 即将 Modal 包裹与 Scrollview 中, 于是顺腾摸瓜, 找到了这个 issue, 文中提到 ScrollviewFlatlist 提供了 keyboardshouldpersisttaps 属性, 这个属性可以控制在弹出软键盘时点击屏幕时的处理方式, 中文文档如下

keyboardShouldPersistTaps

如果当前界面有软键盘,那么点击 scrollview 后是否收起键盘,取决于本属性的设置。(译注:很多人反应 TextInput 无法自动失去焦点/需要点击多次切换到其他组件等等问题,其关键都是需要将 TextInput 放到 ScrollView 中再设置本属性)

  • 'never' (默认值),点击 TextInput 以外的子组件会使当前的软键盘收起。此时子元素不会收到点击事件。
  • 'always',键盘不会自动收起,ScrollView 也不会捕捉点击事件,但子组件可以捕获。
  • 'handled',当点击事件被子组件捕获时,键盘不会自动收起。这样切换 TextInput 时键盘可以保持状态。多数带有 TextInput 的情况下你应该选择此项。
  • false,已过时,请使用'never'代替。
  • true,已过时,请使用'always'代替。

虽然将 keyboardshouldpersisttaps 设置为 handled解决了笔者的实际问题, 但是此时还有一个问号放在笔者心中, 可以看到 keyboardshouldpersisttaps 默认值为never`, 实际开发的页面也没有显示软键盘, 为什么还要设置这个属性才能让点击事件生效?

Expo 的扫码闪退

最开始我是使用 expo-barcode-scanner 处理扫码功能, 后来发现 expo-barcode-scanner 这个 Modal 切换之后打次打开会黑屏, 随换成 expo-camera, 但是 expo-canera 在 expo 43 之后需要安装 expo-face-detector 和 expo-barcode-scanner 才能正常工作, 否则会直接 clash, 于是笔者又换回 expo-barcode-scanner, 并且在探索中发现只要将 Modal 的 visible 等待 100ms 后在修改才可以避免二次打开黑屏, 除了这种 hack 方式只能等待 expo 更新才能解决了. 相关 issue github.com/expo/expo/i…

深入开发问题

Monorepo

在开发本项目的同时, 也要开发一些别的项目, 所以使用 [Monorepo](https://en.wikipedia.org/wiki/Monorepo)的方式来共同代码和管理项目. 在笔者开发时, 恰好使用了 Expo 44, 所以直接参考官方文档就成功切换到了 Monorepo, 但是也为将来踩坑埋下了伏笔. 下文中提到的 Expo 老构建系统简称为: classic, EAS 新构建系统简称为 eas. 大多数问题文档中都已提到, 但还是有些问题笔者要提下.

公共代码开发

项目初期时, 项目结构为:

  • App1
    • assets 资源文件(图标, 字体, 多语言...)
    • src 代码文件
      • screens 页面
      • shared 公共方法
        • array.utility.ts 数组相关的 js 方法, 只依赖于 js 引擎.
        • math.utility.ts 数学相关的 js 方法, 只依赖于 js 引擎.
        • useXX 一些 Hooks, 依赖于 RN, 或者 Expo...

其他目录不在赘述. 切换到 Monorepo后, 项目结构为:

  • Root
    • packages.json 提供一些常用的命名.
    • apps
      • App1
        • assets 资源文件(图标, 字体, 多语言...)
        • src 代码文件
          • screens 页面
          • shared 公共方法
            • useXX 一些 Hooks, 依赖于 RN, 或者 Expo...
      • App2
    • packages
      • shared
        • lib 调用 tsc xx后生成的 .js,.d.ts 文件.
        • src
          • array.utility.ts 数组相关的 js 方法, 只依赖于 js 引擎.
          • math.utility.ts 数学相关的 js 方法, 只依赖于 js 引擎.

对比可以发现, 原本在 App1/shared目录的 array.utility.tsmath.utility.ts都被移动到了 packafes/shared/src, 同时如果在 App1App2中引用 shared目录的代码, 我都会从 packages/shared/lib引用, lib 指代码被编译之后输出的文件夹. 如果使用 classic是没有问题的, 但是在一次需求讨论后增加了 firebase的消息推送, 如果要增加消息推送则需要引入 [rnfirebase](https://rnfirebase.io/), 如果要引入这个库, 就要使用 eas 构建系统, 而 eas 构建系统并没有上传 lib文件夹, 从而使得云打包失败. 为了解决这个问题, 当时真的是急破了脑袋, 不过好在最后找到了解决办法: docs.expo.dev/build-refer… 为了方便开发, npm 提供一些 hook, 例如 preinstall会在运行 npm install之前触发. 同样 eas 也提供了一些 hook, 这里我使用了 eas-build-post-install来解决问题.

{
  "main": "index.js",
  "version": "1.0.0",
  "scripts": {
    "build": "echo 'Nothing to build'",
    "eas-build-post-install": "cd ../.. && yarn build && yarn run:sync-images"
  },
  "dependencies": {
  },
  "devDependencies": {
  }
}

上面代码最重要的有两个 buildeas-build-post-install. build: 很简单, 仅仅 echo 一个字符串. eas-build-post-install: 返回到根目录, 然后执行 yarn build,在 monorepo 下, yarn build会执行所有 workspace 内的 package.json 的 build命令. 这样就可以实现不上传 packages/shared/lib, 也可以构建成功, 因为在 expo 服务器上安装项目依赖后, 会自动执行 eas-build-post-install来生成 lib 文件夹.

等等, 问题好像解决了, 但是源头还未解决, 那就是: 为啥不上传 lib 文件夹!!经过我一番(很长时间的研究), 终于发现了这个 docs.expo.dev/build-refer…, 原因是 eas 在上传文件时会根据 .gitignore 忽略文件, 而我在项目中是忽略掉部分项目的 lib文件夹的, 所以 eas 没有上传.

忽略文件上传

好, 至此, 原有的问题都解决了, 同时项目进入了一个新的阶段, 笔者除了原来的 app 项目, 也开始负责别的项目,新的项目结构为:

  • Root
    • packages.json 提供一些常用的命令.
    • apps
      • App3 新项目, 另一个 App
      • App1 RN 项目1, 使用 expo 构建
        • assets 资源文件(图标, 字体, 多语言...)
        • src 代码文件
          • screens 页面
          • shared 公共方法
            • useXX 一些 Hooks, 依赖于 RN, 或者 Expo...
      • App2 RN 项目2, 使用 expo 构建
    • packages
      • shared
        • lib 调用 tsc xx后生成的 .js,.d.ts 文件.
        • src
          • array.utility.ts 数组相关的 js 方法, 只依赖于 js 引擎.
          • math.utility.ts 数学相关的 js 方法, 只依赖于 js 引擎.

基于 monorepo 的开发模式, 有很多代码都得到了复用, 就在笔者美滋滋开发的时候, 测试发现了 App1中的一些问题, 于是去修改 App1, 很好, 一切正常, 开始上传打包.. 等等, 我上传的文件怎么变成了 50M, 原来只要上传 10M 左右, 发生什么事了, 发生什么事了, 发生什么事了不过由于此时项目开发进度比较紧张, 只好先搁置一旁, 这一搁置就是一个月, 途中又开了一个 h5 的小项目, 此时项目结构变成了:

  • Root
    • packages.json 提供一些常用的命令.
    • apps
      • App3 新项目, 另一个 App
      • App4 新 H5 项目
      • App1 RN 项目1, 使用 expo 构建
      • App2 RN 项目2, 使用 expo 构建
    • packages
      • shared

可以看到, 又多了一个 App4, 此时 App1云打包时体积已经变成了夸张的 100M,尽管如此, 打包结果仍然正常, 考虑到开发进度仍未处理这个问题, 不过在此时笔者已经猜测到了上传文件增加的原因是因为把其他项目也上传了, 同时在某次浏览 issue 时发现, 可以通过 eas build --profile production --platform ios --local最后的 --local使用本机打包, 从而看到打包上传的文件都有哪些.

此时, 已经得出了两个结论:

  1. eas 打包时遵循 .gitignore 文件规则, 会忽略部分文件.
  2. 解开本地打包的文件夹, 发现 App2, App3, App4也被打包进了要上传的文件夹.

基于上面两个结论, 我发现想要解决这个问题是不可能的, 因为 App2, App3, App4确实需要被加入到 VSC 中, 想要解决只能给 eas-cli 发 pr, 或者提需求, 所以开始另辟蹊径, 试图通过 metro打包时忽略App2, App3, App4, 当然, 事实证明这两个根本不是一件事.

直到上个月在 eas-cli 的源码里发现了这个, 看起来这个 .easignore很可疑, 于是开始顺腾摸瓜, 虽然没有在 expo 文档中找到相关的使用说明, 但是找到了两个相关的 issue github.com/expo/eas-cl… github.com/expo/eas-cl… 虽然从现在的角度看, 直接看 eas-cli 的源码是最快了解情况的方式, 但当时第一想法还是先找文档, 最终在两个 issue 中发现, .easignore 可以继承 .gitignore, 并且 gitignore 之外的情况, 此时目录结构为:

  • Root
    • packages.json 提供一些常用的命令.
    • apps
      • App3 新项目, 另一个 App
      • App4 新 H5 项目
      • App1 RN 项目1, 使用 expo 构建
        • .easignore (内容如下, 每行一个, 意图为忽略 App2,3,4 文件夹)
          • App2
          • App3
          • App4
      • App2 RN 项目2, 使用 expo 构建
    • packages
      • shared

笔者再次开心的执行了 eas build --profile production --platform ios --local, 但是得到的结果仍然是 100M的体积, 此时笔者已经心力交瘁, 无心在解决这个问题, 直到一个月后, 在调试另一个坑时, 解压压缩包时发现, .easignore 应该放在 Root 下面才会生效, 这时终于茅塞顿开, 将目录结构改为:

  • Root
    • packages.json 提供一些常用的命令.
    • .easignore 注意, 移动到了根目录 (内容如下, 每行一个, 意图为忽略 App2,3,4 文件夹)
      • App2
      • App3
      • App4
    • apps
      • App3 新项目, 另一个 App
      • App4 新 H5 项目
      • App1 RN 项目1, 使用 expo 构建
      • App2 RN 项目2, 使用 expo 构建
    • packages
      • shared

此时, 忽略终于生效了, App2,3,4 都没有被打包进来, 但是新的问题又出现了, App2 也是使用 expo 来构建, 如果忽略掉就没法打包了, 不过问题能解决到这种程度笔者已经很满意了, 剩下的问题有时间在慢慢解决吧, 最终目录如下

  • Root
    • packages.json 提供一些常用的命令.
    • .easignore 注意, 移动到了根目录 (内容如下, 每行一个, 意图为忽略 react-apps 下面的文件)
      • react-apps
    • react-apps
      • App3 新项目, 另一个 App
      • App4 新 H5 项目
    • expo-apps
      • App1 RN 项目1, 使用 expo 构建
      • App2 RN 项目2, 使用 expo 构建
    • packages
      • shared

我的 .easignore 分享

# 忽略 React App
react-apps
# 忽略 Vue App
vue-apps

node_modules/**/*
# 忽略 expo 生成的文件
.expo/*
# 忽略 IDEA 生成的文件
.idea
# 忽略生成的热更新文件夹
dist
# 忽略 react-native-web 打包文件
web-build

# 忽略 yarn2 在 node-linker 为 node_modules 时生成的文件
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

下一篇文章将会讲一讲如何搭建基于 Expo 开发项目热更新服务.