React Native 练习时长 2 月半,踩坑总结

本文用 react native 做的 app 很简单,首页 + 列表页 + 详情页 + 关于页。总的感觉,涉及到原生方面,对于不会 android 和 ios 的 js 菜鸡,比较棘手,要折腾。

关于Headless JS后台任务,可以参考《React Native Android 端Headless JS后台 GPS 持续定位》这篇文章

1. 前置基础

  1. React 基础
  2. React Native 文档

2. 本文主要 package version

  1. 这里页面路由应该使用 react-navigation 更方便些,react-router-native6.x 版本感觉对 react native 支持不是很好
  2. 对于 UI 库,没找到合适的,找了一个老掉牙的ant-design-react-native
package version
react 18.2.0
react-native 0.71.2
react-router-native ^6.8.0
react-native-webview ^11.26.1
@ant-design/react-native ^5.0.3

3. 关于 React Native 的一些疑问

3.1 React Native 和 Flutter 优缺点对比

来自 chat gpt 的回答,感觉有些落后啊

优点 缺点
React Native 1. 拥有大量第三方库和社区支持;
2. 可以复用 React 的开发经验,语法上非常接近 React;
3. 支持热重载,开发效率高
1. 部署和打包过程较复杂;
2. 动画和性能体验略逊色于原生;
3. 升级过程可能会比较痛苦
Flutter 1. 组件丰富,UI 体验接近原;
2. 性能很高,特别是动画方面;
3. 打包简单,可以编译成原生代码;
4. 升级过程比较顺畅
1. 第三方库和社区还在发展,不如 React Native 成熟 ;
2. 对 Dart 语言的依赖,有一定学习成本;
3. 热重载不能像 React Native 一样方便

3.2 如何看待 react native 到目前为止,还没出 1.0 版本?

同样来自 chat gpt 的回答

React Native 虽然至今还没发布 1.0 版本,但这不意味着它不成熟或不可用。

  1. Facebook 的版本发布策略是非常慢慢的,要达到 1.0 需要非常稳定和完善,这需要长期的迭代和磨合。
  2. React Native 生态庞大,涉及的平台和版本组合非常多,要保证在所有环境下 100% 稳定并不容易。
  3. React Native 的升级过程中,会涉及到原生代码的迁移,这也增加了版本发布的难度。
  4. 社区提供的第三方库也需要跟上版本迭代,这需要协调和校准,也是版本发布的阻碍。

3.3 react native 使用的是原生 view 还是 webview?

同样来自 chat gpt 的回答

  • React Native 使用的是原生 View 而不是 WebView
  • React Native 的核心思想是使用 JavaScript 来开发移动应用,而不是 Web 应用。它允许你使用 React 来开发原生移动应用,最终编译成真实的原生 UI 元素
  • 具体来说,React Native 将 React 组件映射到原生 View 和 Text 组件上,而不是 WebView。所以当你在 JavaScript 中构建一个 Button 时,在本质上它会变成 Android 的 Button 或 iOS 的 UIButton。

4. 开发环境准备

本文选择了本地搭建环境,没有选择依赖线上的 expo 沙盒环境

本地环境搭建文档,对着文档一步步来即可

  1. 创建项目
1
npx react-native init AwesomeProject
  1. 使用安卓手机调试

也可以使用模拟器,由于我手机是安卓的,就用了安卓手机。还有就是安卓模拟器上的显示和真机有些差距,问题没及时暴露。

手机打开开发者模式,并打开 usb 调试,数据线连接电脑即可,运行项目的时候,会自动在你的手机上安卓 debug 安装包

5. 开发时遇到的一些问题总结

根据 Ant Design Mobile RN of React 文档链接字体图标,安装完@ant-design/icons-react-native库后,需要执行npx react-native link @ant-design/icons-react-native,然后出现如下报错

解决:

  1. 安装 react-native-asset
1
yarn add react-native-asset --save
  1. 根目录 react-native.config.js,assets 添加字体图标文件的路径
1
2
3
module.exports = {
assets: ['node_modules/@ant-design/icons-react-native/fonts']
};
  1. 执行 yarn react-native-asset
1
yarn react-native-asset
  1. 检查是否链接成功,android\app\src\main\assets 下是否有 fonts 文件夹

  1. 关于项目中使用了@dr.pogodin/react-native-static-server

感谢 @小白菜 大佬的提供,react-native-static-server是一款可以在react native中,本地启动静态服务的库,文档中有提到android/app/build.gradle配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
android {
sourceSets {
main {
assets.srcDirs = [
'../../assets'
// This array may contain additional asset folders to bundle-in.
// Paths in this array are relative to "build.gradle" file, and
// should be comma-separated.
]
}
}
// ... Other stuff.
}

这样配置过后,会导致@ant-design/icons-react-native字体无法正确加载。如果app内有其它静态资源,在配置assets.srcDirs的时候需要把 = 换成 +=,这样就可以加载字体图标了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
android {
sourceSets {
main {
- assets.srcDirs = [
+ assets.srcDirs += [
'../../assets'
// This array may contain additional asset folders to bundle-in.
// Paths in this array are relative to "build.gradle" file, and
// should be comma-separated.
]
}
}
// ... Other stuff.
}

5.2 如何像 web 项目一样使用 env 环境变量?

如何像 web 项目一样,本地开发使用.env.development文件,生产打包的时候使用.env.production文件,.env.local文件只在本地生效?

  1. 安装react-native-config及配置步骤
  • 安装
1
yarn add react-native-config
  • android/settings.gradle
1
2
+ include ':react-native-config'
+ project(':react-native-config').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-config/android')
  • android/app/build.gradle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
android {
// project.ext.defaultEnvFile = "path/to/.env.file"
// 写在 apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" 这行之前
+ project.ext.envConfigFiles = [
+ debug: ".env.development",
+ release: ".env.production"
+ ]

defaultConfig {
applicationId "com.xxx.xxx"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2
versionName "0.0.2"
// for react-native-config
+ resValue "string", "build_config_package", "com.xxx.xxx"
}
}

dependencies {
implementation "com.facebook.react:react-native:+" // From node_modules
+ implementation project(':react-native-config')
}

// 最后一行添加
+ apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
  • android/app/src/main/java/com/your-app-name/MainApplication.java
1
+ import com.lugg.RNCConfig.RNCConfigPackage;
  1. package.json 中 添加 scripts 脚本命令

.env.local加入到.gitignore文件中忽略,文件中可以放入一些私有敏感变量,不会被提交到仓库里;

本地开发和生产打包时将.env.local文件里的变量复制到.env.development.env.production文件里,启动项目完成或者打包完后,再将新复制进来的变量删除;

这样就解决了 debug 和 release 变量区分,而本地私有变量也不会直接暴露出去。

  • 新建.env.local 文件
1
2
3
# 高德地图key
# android
AMP_ANDROID_API_KEY=xxx
  • 新建.env.development 文件
1
2
3
NODE_ENV=development
BASE_URL=https://xxx.dev.api.com
MY_VARIABLE_ENV=123dev
  • 新建.env.production 文件
1
2
3
NODE_ENV=production
BASE_URL=https://xxx.prod.api.com
MY_VARIABLE_ENV=123prod
  • package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"scripts": {
"preandroid": "node ./scripts/checkEnvAmapKey .env.local && cat .env.local >> .env.development",
"android": "ENVFILE=.env.development react-native run-android",
"postandroid": "node ./scripts/revertEnvFile .env.development .env.local",
"prebuild:android": "node ./scripts/checkEnvAmapKey .env.local && cat .env.local >> .env.production",
"build:android": "cd android && ENVFILE=.env.production ./gradlew assembleRelease",
"postbuild:android": "node ./scripts/revertEnvFile .env.production .env.local",
"prebuild:android:aab": "node ./scripts/checkEnvAmapKey .env.local && cat .env.local >> .env.production",
"build:android:aab": "cd android && ENVFILE=.env.production ./gradlew bundleRelease",
"postbuild:android:aab": "node ./scripts/revertEnvFile .env.production .env.local"
}
}
  • scripts/checkEnvAmapKey.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
const path = require("path");
const fs = require("fs");
const { Buffer } = require("buffer");
const argv = process.argv.slice(2);
const rootDir = process.cwd();
const fsPromise = fs.promises;
const chalk = require("chalk");
// const shell = require('shelljs');

console.log("argv:", argv); // [ '.env.development', '.env.local' ]

const envLocalFilePath = path.resolve(rootDir, `./${argv[0]}`);
const envLocalFileTemplate = `
# 高德地图key
# android
AMP_ANDROID_API_KEY=请输入您的android api key
`;

const handleWrite = async () => {
try {
// https://nodejs.org/docs/latest-v16.x/api/fs.html#fspromiseswritefilefile-data-options
const controller = new AbortController();
const { signal } = controller;
const data = new Uint8Array(Buffer.from(envLocalFileTemplate));

const promise = await fsPromise.writeFile(envLocalFilePath, data, {
signal,
encoding: "utf8",
flags: "w", // 以写入模式打开文件,如果文件不存在则创建
});

// Abort the request before the promise settles.
controller.abort();

await promise;
} catch (error) {
console.log(chalk.red("写入文件失败"), error);
} finally {
process.exit(1);
}
};

async function writeEnvAmapKeyFile() {
// https://nodejs.org/docs/latest-v16.x/api/fs.html#fsexistspath-callback
fs.open(envLocalFilePath, "wx", async (err, fd) => {
if (err) {
if (err.code === "EEXIST") {
console.log(`${envLocalFilePath}文件已存在`);
}
process.exit(1);
return;
}

try {
await handleWrite();
} finally {
fs.close(fd, (error) => {
if (error) {
throw error;
}
});
process.exit(1);
}
});
}

// 检查本地根目录是否存在`.env.local`文件
async function checkEnvAmapKeyFile() {
try {
await fsPromise.stat(envLocalFilePath);
console.log(`${chalk.green("项目根目录已存在 `.env.local` 文件")};`);
} catch (error) {
console.log(`
${chalk.red.bgYellow("项目根目录不存在 `.env.local` 文件,将进行创建")};
${chalk.yellow(
"请在生成的`.env.local`文件中填入高德地图API KEY,未申请的请前往高德地图 https://console.amap.com/dev/key/app 中申请创建,申请成功后,请复制key到 `.env.local`文件相应位置,然后重新启动项目"
)}
`);

// shell.exec('touch \\.env.local');
await writeEnvAmapKeyFile();
}
}

checkEnvAmapKeyFile();
  • scripts/revertEnvFile.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
const path = require("path");
const fs = require("fs");
const { Buffer } = require("buffer");
const argv = process.argv.slice(2);
const rootDir = process.cwd();
const fsPromise = fs.promises;
const chalk = require("chalk");

console.log("argv:", argv); // [ '.env.development', '.env.local' ]

const envLocalFilePath = path.resolve(rootDir, `./${argv[1]}`);
const envFilePath = path.resolve(rootDir, `./${argv[0]}`);

const handleWrite = async (filePath, data) => {
try {
// https://nodejs.org/docs/latest-v16.x/api/fs.html#fspromiseswritefilefile-data-options
const controller = new AbortController();
const { signal } = controller;
const str = new Uint8Array(Buffer.from(data));
const promise = await fsPromise.writeFile(filePath, str, {
signal,
encoding: "utf8",
flags: "w", // 以写入模式打开文件,如果文件不存在则创建
});

// Abort the request before the promise settles.
controller.abort();

await promise;
} catch (error) {
console.log(chalk.red("写入文件失败"), error);
} finally {
process.exit(1);
}
};

async function writeEnvAmapKeyFile(filePath, data) {
// https://nodejs.org/docs/latest-v16.x/api/fs.html#fsexistspath-callback
fs.open(filePath, "wx", async (err, fd) => {
if (err) {
if (err.code === "EEXIST") {
await handleWrite(filePath, data);
}
process.exit(1);
return;
}

try {
await handleWrite(filePath, data);
} finally {
fs.close(fd, (error) => {
if (error) {
throw error;
}
});
process.exit(1);
}
});
}

// 读取文件内容
async function handleReadFile(fileName) {
try {
const promise = fsPromise.readFile(fileName, { encoding: "utf8" });

return await promise;
} catch (err) {
// When a request is aborted - err is an AbortError
console.error(err);
return "";
}
}

// 检查本地根目录是否存在`.env.local`文件
async function handleRevertEnvFile() {
try {
await fsPromise.stat(envLocalFilePath);

const envAmapFileContent = await handleReadFile(envLocalFilePath);
const envFileContent = await handleReadFile(envFilePath);

if (envAmapFileContent === "") {
return;
}

const replaceStr = envFileContent.replace(envAmapFileContent, "");

await writeEnvAmapKeyFile(envFilePath, replaceStr);
} catch (error) {
console.log(error);
console.log(
`${chalk.red.bgYellow(
"请检查 `.env.[development|production]` 文件,若有新增加的高德地图 API KEY,请撤回更改,勿提交到远程仓库"
)}`
);
}
}

handleRevertEnvFile();
  1. 关于打 release 生产包时获取不到自定义 env 变量

https://github.com/luggit/react-native-config/issues/640

android/app/proguard-rules.pro 添加下面这行:

1
-keep class com.mypackage.BuildConfig { *; }
  1. 遗留的小问题

本地开发时,执行npm run android,npm scripts 钩子执行不了postandroid,导致复制到.env.development文件里的变量删除不了。npm run build:android打包的时候不存在这个问题。

5.3 优雅的修改包的版本号

https://github.com/stovmascript/react-native-version

  • 安装
1
yarn add react-native-version --dev
  • package.json 中添加 scripts 脚本
1
2
3
4
5
6
7
8
{
"name": "AwesomeProject",
"version": "0.0.1",
"scripts": {
"start": "node node_modules/react-native/local-cli/cli.js start",
+ "postversion": "react-native-version"
}
}
  • 使用

每次打包发布前,先执行npm version x.x.x它会自动把新的版本号更新到 android 和 ios 中,然后再执行打包命令即可

5.4 修改包的名称

修改 android/app/src/main/res/values/strings.xml

1
2
3
<resources>
<string name="app_name">修改成你想要的包名</string>
</resources>

5.5 打包图片报错 mergeReleaseResources FAILED

  • 代码中使用图片的地方
1
2
3
4
5
6
7
8
9
10
import { ImageBackground, useWindowDimensions } from "react-native";

<ImageBackground
source={require("../../assets/img/mybg.png")}
resizeMode="cover"
style={{
...styles.backgroundImg,
height: useWindowDimensions().height,
}}
/>;
  • 报错如下
1
2
3
4
5
* What went wrong:
Execution failed for task ':app:mergeReleaseResources'.
> A failure occurred while executing com.android.build.gradle.internal.res.Aapt2CompileRunnable
> Android resource compilation failed
ERROR:/android/app/build/generated/res/react/release/drawable-mdpi/src_assets_img_bg.png: AAPT: error: file failed to compile.
  • 解决:

android\app 下的 build.gradle 文件中添加如下代码

1
2
3
4
5
6
7
android {
...
+ // 解决打包png图片报错
+ aaptOptions.cruncherEnabled = false
+ aaptOptions.useNewCruncher = false
...
}

5.6 本地开发 http 请求失败

两种方式,推荐第一种

  1. 使用 https 请求

  2. 修改配置

  • 在 res 下新增加一个 xml 目录,然后创建一个名为 network_security_config.xml 文件,文件内容
1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
  • 在 android/app/src/main/AndroidManifest.xml 文件中添加:
1
2
3
<application>
+ android:networkSecurityConfig="@xml/network_security_config"
</application>

5.7 安卓 Text 组件文字显示不全

设置 fontFamily 为 lucida grande,或者为空

1
fontfamily: "lucida grande";

5.8 android 绝对定位时点击事件失效

https://segmentfault.com/q/1010000022868789

解决: 在最外面在加一个 View,固定高度,点击就可以触发

5.9 android 侧滑退出应用问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import React, { useEffect, useCallback, memo, Fragment, useRef } from "react";
import {
Platform,
BackHandler,
Text,
StyleSheet,
AppState,
ToastAndroid,
} from "react-native";
import { useNavigate, useLocation } from "react-router-native";
import { Toast } from "@ant-design/react-native";

const ResetBack = () => {
const navigate = useNavigate();
const location = useLocation();

const numRef = useRef(1);

const _handleAppStateChange = function (nextAppState) {
// console.log(appState);
console.log("nextAppState==>", nextAppState);

if (nextAppState && nextAppState === "background" && numRef.current > 0) {
console.log("numRef.current==>", numRef.current);
if (Platform.OS === "android") {
ToastAndroid.showWithGravity(
"已切到后台",
ToastAndroid.SHORT,
ToastAndroid.BOTTOM
);
}
}
};

const handleHardwareBackPress = useCallback(() => {
if (location.pathname === "/") {
numRef.current--;

if (numRef.current === 0) {
Toast.info({
content: <Text style={styles.txt}>在滑一次退出</Text>,
});

BackHandler.removeEventListener(
"hardwareBackPress",
handleHardwareBackPress
);

return true;
}
}

navigate(-1);
return true;
}, [navigate, location]);

useEffect(() => {
// AppState.addEventListener('change', _handleAppStateChange);
// https://github.com/facebook/react-native/issues/34644#issuecomment-1245026317
const listener = AppState.addEventListener("change", _handleAppStateChange);

return () => {
// AppState.removeEventListener('change', _handleAppStateChange);
listener.remove();
};
}, []);

useEffect(() => {
// 禁用 Android 上的返回按钮(侧滑返回)
if (Platform.OS === "android") {
BackHandler.addEventListener(
"hardwareBackPress",
handleHardwareBackPress
);
}

return () => {
if (Platform.OS === "android") {
BackHandler.removeEventListener(
"hardwareBackPress",
handleHardwareBackPress
);

numRef.current = 1;
}
};
}, [handleHardwareBackPress]);

return <Fragment />;
};

const styles = StyleSheet.create({
txt: {
fontFamily: "",
color: "#ffffff",
},
});

export default memo(ResetBack);

5.10 自定义双击事件 Button 组件

chatgpt 给我的答案

在 rn 0.71 及更高版本中,可以使用 TouchableWithoutFeedback 组件来捕获双击事件。

TouchableWithoutFeedback 提供了 onPress 和 onLongPress 回调函数以及 delayPressIn 和 delayPressOut 属性,这些属性可以用于检测双击事件

您可以根据需要调整 delayPressIn 和 delayPressOut 属性的值。例如,如果您希望用户必须在 500 毫秒内双击按钮,则可以将 DOUBLE_PRESS_DELAY 常量设置为 500。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import React, { useRef } from "react";
import { TouchableWithoutFeedback, Text } from "react-native";

const MyDoubleClickButton = (props) => {
// doubleClickTime为双击完成的时间
const { onPress, title, doubleClickTime, textStyle } = props;
const lastPress = useRef(0);

const handlePress = () => {
const now = new Date().getTime();
const DOUBLE_PRESS_DELAY = doubleClickTime;
if (now - lastPress.current < DOUBLE_PRESS_DELAY) {
onPress();
}
lastPress.current = now;
};

return (
<TouchableWithoutFeedback
onPress={handlePress}
delayPressIn={doubleClickTime / 2}
delayPressOut={doubleClickTime / 2}
>
<Text style={textStyle}>{title}</Text>
</TouchableWithoutFeedback>
);
};

export default MyDoubleClickButton;

5.11 设置剪切板

https://github.com/react-native-clipboard/clipboard

1
2
3
4
5
6
7
8
9
import Clipboard from "@react-native-clipboard/clipboard";

// 设置值:
Clipboard.setString("hello world");
// 取值:
const fetchCopiedText = async () => {
const text = await Clipboard.getString();
setCopiedText(text);
};

5.12 关于使用react-native-webview

https://github.com/react-native-webview/react-native-webview/blob/HEAD/docs/Reference.md

  1. 安装
1
yarn add  react-native-webview

注意: 一定要在安装完 react-native-webview 之后,然后重新启动,才会生效,不然会报错
(重启后,会重新下载依赖,这个过程,视网络情况而定,可能有点慢)

1
TypeError: Cannot read property 'isFileUploadSupported' of null, js engine: hermes
  1. 网页 js 和原生进行通信

如下示例,给网页插入一段脚本,获取网页上的图片点击事件,并且拿到图片的 src 链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React, { Component } from "react";
import { View } from "react-native";
import { WebView } from "react-native-webview";

const INJECTED_JAVASCRIPT = `
var imgList = document.querySelectorAll("img");

Array.from(imgList).forEach(el => {
el.addEventListener("click", function () {
window.ReactNativeWebView.postMessage(JSON.stringify({type: 'previewimg', data: this.src}));
});
});

// 注意:这行是必须添加的,否则添加失败
true; // note: this is required, or you'll sometimes get silent failures
`;

const MyWebView = () => {
return (
<WebView
source={{ uri: `http://xxx/posts/detail/123` }}
injectedJavaScript={INJECTED_JAVASCRIPT}
onMessage={(event) => {
alert(event.nativeEvent.data);
}}
/>
);
};
  1. IOS 报错 RNCWebView 未找到 UIManager

报错如下:

1
Invariant Violation: requireNativeComponent: "RNCWebView" was not found in the UIManager

解决:

1
2
3
cd ios

pod install

然后重启项目即可

  1. WebView 页面不显示

关键原因是,WebView 的父组件没有设置高度,导致 WebView 页面不显示

解决: 给 WebView 父组件设置一个高度即可

5.13 关于使用高德地图

https://github.com/qiuxiang/react-native-amap3d

定位模块@react-native-community/geolocation获取的坐标是 gps 坐标,高德地图使用时要转换, react-native-amap-geolocation可以直接获取到位置信息,无需再进行转换

  1. 安装
1
yarn add react-native-amap3d
  1. android/app/src/main/AndroidManifest.xml添加权限
1
2
3
4
<!-- 精确定位 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- 模糊定位 -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
  1. 使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { AMapSdk, MapView, MapType } from "react-native-amap3d";
import { Platform, PermissionsAndroid } from "react-native";

// 申请定位权限
const handleAndroidPermissin = useCallback(async () => {
try {
const granted = await PermissionsAndroid.requestMultiple(
[
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
],
{
title: "位置信息授权",
message: "获取当前位置信息测试",
buttonNeutral: "跳过",
buttonNegative: "取消",
buttonPositive: "同意",
}
);

console.log("granted==>", granted);

if (
granted["android.permission.ACCESS_FINE_LOCATION"] ===
PermissionsAndroid.RESULTS.GRANTED
) {
console.log("可以定位了");
} else if (
granted["android.permission.ACCESS_FINE_LOCATION"] ===
PermissionsAndroid.RESULTS.DENIED
) {
Toast.fail({
content: "已拒绝获取位置信息",
});
} else {
Toast.fail({
content: "用户已拒绝,且不愿被再次询问",
});
}
} catch (error) {
console.warn(error);
}
}, []);

useEffect(() => {
AMapSdk.init(
Platform.select({
android: Config.AMP_ANDROID_API_KEY,
})
);
}, []);

return <MapView mapType={MapType.Satellite} />;
  1. 关于 GPS 坐标转高德坐标

https://www.jianshu.com/p/dd0c017250e4

推荐使用高德坐标转换

  1. 高德地图逆地理编码服务

https://lbs.amap.com/api/javascript-api-v2/guide/services/geocoder#t2

1
2
3
4
5
6
7
8
9
10
11
12
13
AMap.plugin("AMap.Geocoder", function () {
var geocoder = new AMap.Geocoder({
city: "010", // city 指定进行编码查询的城市,支持传入城市名、adcode 和 citycode
});

var lnglat = [111, 30];

geocoder.getAddress(lnglat, function (status, result) {
if (status === "complete" && result.info === "OK") {
// result为对应的地理位置详细信息
}
});
});
  1. 安卓从地图页返回上一页 app 闪退

https://github.com/qiuxiang/react-native-amap3d/issues/742

解决android/app/src/main/AndroidManifest.xml文件application添加android:allowNativeHeapPointerTagging="false"

  1. 打正式 release 包闪退

https://github.com/qiuxiang/react-native-amap3d/issues/762

解决:在 android/appproguard-rules.pro 文件添加下面代码,重新打包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 高德地图release包闪退问题
# 3D 地图 V5.0.0之前:
-keep class com.amap.api.maps.**{*;}
-keep class com.autonavi.amap.mapcore.*{*;}
-keep class com.amap.api.trace.**{*;}

# 3D 地图 V5.0.0之后:
-keep class com.amap.api.maps.**{*;}
-keep class com.autonavi.**{*;}
-keep class com.amap.api.trace.**{*;}

# 定位
-keep class com.amap.api.location.**{*;}
-keep class com.amap.api.fence.**{*;}
-keep class com.autonavi.aps.amapapi.model.**{*;}

# 搜索
-keep class com.amap.api.services.**{*;}

# 2D地图
-keep class com.amap.api.maps2d.**{*;}
-keep class com.amap.api.mapcore2d.**{*;}

# 导航
-keep class com.amap.api.navi.**{*;}
-keep class com.autonavi.**{*;}

6. 自定义安卓原生模块供 js 端使用

https://www.reactnative.cn/docs/native-modules-android

基本步骤是:

  1. 创建模块:在android/app/src/main/java/com/your-app-name下新建一个 java 文件,如ToastModule.java

  2. 注册模块:在android/app/src/main/java/com/your-app-name下新建一个 java 文件,如CustomToastPackage.java

  3. android/app/src/main/java/com/your-app-name/MainApplication.java中引入自己的包

  4. js 端通过暴露出来的模块名调用原生方法

6.1 调用自定义原生安卓模块-手电筒

自定义封装一个手电筒模块,供 js 端调用,可以打开/关闭手机的手电筒

  1. android/app/src/main/AndroidManifest.xml加入如下权限
1
2
3
4
<!-- 摄像头,手电筒 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FLASHLIGHT" />
<uses-feature android:name="android.hardware.camera" />
  1. android/app/src/main/java/com/your-app-name 下新建 FlashlightManModule.java 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
package com.your-app-name;

import android.hardware.Camera;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.CameraCharacteristics;
import android.os.Build;
import android.content.pm.PackageManager;

import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;

import java.util.Map;
import java.util.HashMap;

public class FlashlightManModule extends ReactContextBaseJavaModule {
private static ReactApplicationContext reactContext;
private Camera camera;
private Camera.Parameters mParameters;

private CameraManager mCameraManager;
private boolean hasClosed = true; // 定义开关状态,状态为false,打开状态,状态为true,关闭状态;

private static final int FLASH_LIGHT_ON = 1; // 打开手电筒
private static final int FLASH_LIGHT_OFF = 0; // 关闭手电筒
private static final String E_LAYOUT_ERROR = "E_LAYOUT_ERROR";
private static final String HAS_FLASH = "HAS_FLASH";
private static final String NOT_HAS_FLASH = "NOT_HAS_FLASH";

public FlashlightManModule(ReactApplicationContext context) {
super(context);
reactContext = context;
}

@Override
public String getName() {
return "FlashlightManager";
}

// 是否支持闪光灯
@ReactMethod
public void isSuportFlashlight(Promise promise) {
try {
PackageManager packageManager = reactContext.getPackageManager();
//检查设备是否支持闪光灯
boolean hasFlash = packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH);

String hasFlashStr = hasFlash ? HAS_FLASH : NOT_HAS_FLASH;

if(!hasFlash) {
promise.reject(E_LAYOUT_ERROR, hasFlashStr);
return;
}

CameraManager mCameraManager = (CameraManager) reactContext.getSystemService(ReactContext.CAMERA_SERVICE);
String[] cameraIds = mCameraManager.getCameraIdList();
String cameraId = cameraIds[0];
boolean r = mCameraManager.getCameraCharacteristics(cameraId).get(CameraCharacteristics.FLASH_INFO_AVAILABLE);
promise.resolve(r);
} catch(CameraAccessException e) {
e.printStackTrace();
promise.reject(E_LAYOUT_ERROR, e);
}
}

// 闪光灯开、关
@ReactMethod
public void toggleLight(int lightType, Promise promise) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// Android7.x之后:
//获取CameraManager
CameraManager mCameraManager = (CameraManager) reactContext.getSystemService(ReactContext.CAMERA_SERVICE);
String[] cameraIds = mCameraManager.getCameraIdList();
String cameraId = cameraIds[0];
hasClosed = lightType == FLASH_LIGHT_ON;
mCameraManager.setTorchMode(cameraId, lightType == FLASH_LIGHT_ON);
} else {
// Android7.x之前代码
if(lightType == FLASH_LIGHT_ON) {
camera = Camera.open();
mParameters = camera.getParameters();
mParameters.setFlashMode(mParameters.FLASH_MODE_TORCH);// 开启
camera.setParameters(mParameters);
hasClosed = false;
} else {
mParameters.setFlashMode(mParameters.FLASH_MODE_OFF);// 关闭
camera.setParameters(mParameters);
camera.release();
hasClosed = true;
}
}


promise.resolve(lightType);
} catch (CameraAccessException e) {
promise.reject(E_LAYOUT_ERROR, e);
}
}
}
  1. android/app/src/main/java/com/your-app-name 下新建 FlashlightManModulePackage.java 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.your-app-name;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class FlashlightManModulePackage implements ReactPackage {

@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}

@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();

modules.add(new FlashlightManModule(reactContext));

return modules;
}

}
  1. android/app/src/main/java/com/your-app-name/MainApplication.java
1
2
3
4
5
6
7
8
9
10
11
...
import com.your-app-name.FlashlightManModulePackage; // <-- 引入你自己的包
...
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new FlashlightManModulePackage()); // <-- 添加这一行,类名替换成你的Package类的名字 name.
return packages;
}
  1. js 端调用上面写的原生模块方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import {NativeModules, PermissionsAndroid} from 'react-native';

// public String getName()中返回的字符串
let FlashlightManager = NativeModules.FlashlightManager;
const lightTypeRef = useRef(false);

// 申请摄像机权限
const handleAndroidPermissin = async () => {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.CAMERA,
{
title: '闪光灯授权',
message: '获取闪光灯权限',
buttonNeutral: '跳过',
buttonNegative: '取消',
buttonPositive: '同意',
},
);

console.log('授权结果granted==>', granted);
if (granted === PermissionsAndroid.RESULTS.GRANTED) {
console.log('可以打开闪光灯了');
return Promise.resolve();
} else if (granted === PermissionsAndroid.RESULTS.DENIED) {
Toast.fail({
content: '已拒绝打开闪光灯',
});
return Promise.reject();
} else {
Toast.fail({
content: '用户已拒绝,且不愿被再次询问',
});
return Promise.reject();
}
} catch (error) {
console.error(error);
}
};

// 打开、关闭手电筒
const toggleLight = async () => {
try {
await handleAndroidPermissin();
const = await FlashlightManager.isSuportFlashlight();
console.log('当前手机是否支持闪光灯:', suportRes);
console.log('准备打开/关闭手电筒:', lightTypeRef.current ? 0 : 1);
const res = await FlashlightManager.toggleLight(
lightTypeRef.current ? 0 : 1,
);

lightTypeRef.current = res;
console.log('手电筒打开/关闭结果:', res);
} catch (error) {
console.error('打开/关闭手电筒异常', error);
}
};

6.2 调用自定义原生安卓模块-Notification 通知

点击某按钮(线上应该是收到服务端消息),手机上出现 app 的通知(自定义通知标题和通知内容,并且携带路径参数),用户点击通知打开 app,接收路径参数,跳转指定页面

  1. android/app/src/main/java/com/your-app-name 新建 MyNotificationModule.java文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package com.your-app-name;

import android.os.Build;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Intent;
import android.app.PendingIntent;

import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;

public class MyNotificationModule extends ReactContextBaseJavaModule {
private static ReactApplicationContext reactContext;
private static final String CHANNEL_ID = "notification_channel";
private static final String CHANNEL_NAME = "notification_channel_name";

private Class getActivityClass(String activityName) {
try {
if (activityName.equals("PageToJumpTo")) {
return Class.forName("com.your-app-name.MainActivity");
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
return null;
}

public MyNotificationModule(ReactApplicationContext context) {
super(context);
reactContext = context;
}

@Override
public String getName() {
return "MyNotificationManager";
}

@ReactMethod
public void show(int contextId, String title, String content, String activityName, String routePath) {
// https://juejin.cn/post/7195496297151381565
// https://blog.csdn.net/canghieever/article/details/125430116

// 创建一个NotificationManager对通知进行管理
NotificationManager mNotificationManager = (NotificationManager) reactContext.getSystemService(ReactContext.NOTIFICATION_SERVICE);
Notification notification = null;

Intent intent = new Intent(reactContext, getActivityClass(activityName));
// Intent intent = new Intent(reactContext, PageToJumpToActivity.class);
// intent可以携带参数到指定页面
intent.putExtra("pageToJumpKey", routePath);

// https://blog.csdn.net/yubo_725/article/details/124413000
// Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified
PendingIntent pendingIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// https://blog.csdn.net/zhangxiweicaochen/article/details/14002469
// PendingIntent.getActivity(context,id, intent,PendingIntent.FLAG_UPDATE_CURRENT);
// 上面id,一定要唯一,不然Intent就是接受到的重复!
pendingIntent = PendingIntent.getActivity(reactContext, contextId, intent, PendingIntent.FLAG_IMMUTABLE);
} else {
pendingIntent = PendingIntent.getActivity(reactContext, contextId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}

if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// 安卓8.0之后
// 创建channel
// 参数:channel ID(ID可以随便定义,但保证全局唯一性),channel 名称,重要等级
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH);
mNotificationManager.createNotificationChannel(channel);

notification = new Notification.Builder(reactContext, CHANNEL_ID)
.setContentTitle(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setAutoCancel(true) // 当点击通知后显示栏的通知不再显示
.setContentIntent(pendingIntent) // 点击跳到通知详情
.build();
} else {
notification = new Notification.Builder(reactContext)
.setContentTitle(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build();
}

// notify(int id,Notification notification)
// 第一个参数id可以随便填,第二个参数就是我们要发送的通知
mNotificationManager.notify(100, notification);
}
}
  1. android/app/src/main/java/com/your-app-name 新建 MyNotificationModulePackage.java文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.your-app-name;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class MyNotificationModulePackage implements ReactPackage {

@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}

@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();

modules.add(new MyNotificationModule(reactContext));

return modules;
}
}
  1. android/app/src/main/java/com/your-app-name/MainApplication.java文件中引入自己的模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
+ import com.your-app-name.MyNotificationModulePackage;

public class MainApplication extends Application implements ReactApplication {
...
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();

+ packages.add(new MyNotificationModulePackage());// <-- 添加这一行,类名替换成你的Package类的名字 name.
return packages;
}
...
}
  1. android/app/src/main/java/com/your-app-name/MainActivity.java 添加代码监听 onNewIntent 事件,获取 MyNotificationModulePackage文件intent.putExtra的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
+ import android.content.Intent;
+ import com.facebook.react.bridge.WritableMap;
+ import com.facebook.react.bridge.Arguments;
+ import com.facebook.react.modules.core.DeviceEventManagerModule;
+ import com.facebook.react.bridge.ReactApplicationContext;
// import com.facebook.react.bridge.ReactContext;
+ import com.facebook.react.ReactInstanceManager;

public class MainActivity extends ReactActivity {
...
+ @Override
+ public void onNewIntent(Intent intent) {
+ // 监听onNewIntent事件
+ super.onNewIntent(intent);
+ String data = intent.getStringExtra("pageToJumpKey");

+ if(data != null) {
+ WritableMap params = Arguments.createMap();
+ params.putString("pageToJumpKey", data);

+ ReactInstanceManager mReactInstanceManager = getReactNativeHost().getReactInstanceManager();
+ ReactApplicationContext context= (ReactApplicationContext) mReactInstanceManager.getCurrentReactContext();

+ context
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
+ .emit("pageToJumpKey", params);
+ }
+ }
}
  1. js 端调用 MyNotificationModule 模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { NativeModules } from "react-native";

let MyNotificationManager = NativeModules.MyNotificationManager;

const handleNotification = async () => {
try {
await handleNotificationPermission();

const ctxId = Math.ceil(Math.random() * 10000000);
MyNotificationManager.show(
ctxId, // android中PendingIntent.getActivity的id,每次调用必须唯一,否则可能拿到旧数据
"文章更新啦!", // 通知标题
"查看新文章", // 通知内容
"PageToJumpTo", // 需要调用android中哪个activity名称
"/list" // 点击通知时需要跳转的路径
);
} catch (error) {
console.log(error);
}
};
  1. js 端监听点击通知传递进来的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import React, { memo, useEffect, Fragment } from "react";
import { NativeEventEmitter, NativeModules } from "react-native";
import { useNavigate } from "react-router-native";

const PageJumpTo = () => {
const navigate = useNavigate();

useEffect(() => {
const eventEmitter = new NativeEventEmitter(
NativeModules.MyNotificationModule
);

const eventListener = eventEmitter.addListener("pageToJumpKey", (data) => {
// data就是MainActivity传过来的params参数
console.log("pageToJumpKey==>", data);
navigate(data.pageToJumpKey);
});

return () => {
eventListener && eventListener.remove(); // 组件卸载时记得移除监听事件
};
}, [navigate]);

return <Fragment />;
};

export default memo(PageJumpTo);

7. 调试

  1. Chrome 浏览器 的 DevTools 来调试 Hermes 上的 JS

文档

  1. react-devtools 调试
1
yarn global add react-devtools
1
npx react-devtools

如果用的是本地手机,还需要执行

1
adb reverse tcp:8097 tcp:8097

8. 打包发布

跟着文档来即可

9. 总结

练习时长 2 月半,远远不够,app 很粗糙,距离上架还有一段距离,况且代码里现在只兼顾到了 android,忽略了 ios。