基于真实环境编写:Apple Silicon Mac + Android Studio / SDK 37 / JDK 17(brew formula 版)+ RN 0.88.1,2026-10 在 Android 16 (API 37) 模拟器上端到端实测跑通。

目录

一、环境检验

1、检验 JDK 17

2、检验 Android SDK 与命令行工具

3、检验模拟器与 AVD

4、总检(可选)

二、项目搭建

1、创建项目并安装依赖

2、★把 Gradle 下载源换成腾讯镜像(Android 侧第一必做)

3、启动模拟器

4、首次构建与安装

5、★手动启动 Metro(Android 侧必踩的静默坑)

6、★★ 授权本地网络访问(Android 16 / API 36+ 模拟器专属,最大暗坑)

7、处理「附近设备」权限弹窗

三、Demo 功能页面搭建

1、安装导航与存储依赖

2、导航骨架(与 iOS 同构,贴 Android 配色)

3、列表页(网络请求 + 三态 + FlatList + 下拉刷新)

4、详情页(参数传递 + ★物理返回键拦截 BackHandler)

5、我的页(★运行时权限申请 PermissionsAndroid + 持久化)

6、运行验证

四、调试能力(Android 模拟器专属)

附录 A:Android 真机调试(比 iOS 简单得多)

附录 B:Android 侧高频报错速查(实战浓缩)


一、环境检验

1、检验 JDK 17

java -version        
# 期待 openjdk version "17.x.x"

echo $JAVA_HOME      
# 期待 /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
  • 原因:Android Gradle Plugin 8+ 强制要求 JDK 17,高了低了都不行。JAVA_HOME 是 Gradle 找 JDK 的方式(JAVA 在 PATH 里但 JAVA_HOME 空着,命令行能跑、Gradle 构建照样崩)。
  • 问题:
    • command not found: JAVA 或 JAVA_HOME 为空 → source ~/.zshrc;还没配就按「Android 环境篇」补 brew openjdk@17 + 环境变量。
    • 显示 21/24 等其他版本 → 用 /usr/libexec/java_home -V 看装了哪些,确认 JAVA_HOME 指向 17。

2、检验 Android SDK 与命令行工具

echo $ANDROID_HOME   
# 期待 /Users/你/Library/Android/sdk

adb version          
# 期待有输出(Android Debug Bridge version 1.0.41)

ls $ANDROID_HOME     
# 期待看到 platforms / build-tools / platform-tools / emulator
  • 原因:ANDROID_HOME 是 RN CLI 定位 SDK 的唯一途径;adb 负责"找到设备→装 APK→转发日志",是 Android 开发的瑞士军刀。
  • 问题:
    • 都为空 → 环境变量没配/没生效:确认 ~/.zshrc 里有 ANDROID_HOME 和 PATH 两段,然后 source ~/.zshrc 重开终端。
    • adb version 报 version 为 installed as 'android-adb'→正常,不影响。

3、检验模拟器与 AVD

emulator -list-avds                        
# 期待列出至少一个(如 Medium_Phone_API_37.0)

ls $ANDROID_HOME/platforms                 
# 看有 android-36/37

ls $ANDROID_HOME/build-tools               
# 看有 36/37
  • 原因:AVD(Android Virtual Device)= 一台虚拟手机的完整配置;platforms/build-tools 是编译目标。Apple Silicon 的系统镜像必须是 arm64(x86 镜像会卡成 PPT 或起不来)。
  • 问题:
    • 列表为空 → Android Studio → Device Manager → Create Device(镜像选带 arm64 标签的)。
    • 有 AVD 但 emulator 命令找不到 → PATH 里少了 $ANDROID_HOME/emulator。

4、总检(可选)

cd 任意 RN 项目目录 && npx react-native doctor
  • 原因:一键扫 Android + iOS 全部依赖项。
  • 问题:个别项误报(尤其 JDK),以步骤 1~3 原始命令输出为准。

二、项目搭建

1、创建项目并安装依赖

mkdir -p ~/rn-playground && cd ~/rn-playground

npx @react-native-community/cli@latest init RnAndroidDemo --skip-install

cd RnAndroidDemo && npm install    
# npmmirror 源下约 1 分钟
  • 方式:同 iOS 篇——CLI 生成模板(自带 android/ 完整 Gradle 工程),--skip-install 分离依赖安装。
  • 原因:android/ 目录含 gradle 配置、AndroidManifest.xml(权限声明处)、原生入口 MainActivity —— Android 侧"原生半身"由 Gradle 管理(对应 iOS 的 CocoaPods)。
  • 问题:
    • 模板/依赖拉取慢 → 确认 npm config get registry 为 npmmirror。
    • 项目名必须大驼峰字母开头

2、★把 Gradle 下载源换成腾讯镜像(Android 侧第一必做)

cd android
sed -i '' 's|services.gradle.org/distributions|mirrors.cloud.tencent.com/gradle|' \
  gradle/wrapper/gradle-wrapper.properties
grep distributionUrl gradle/wrapper/gradle-wrapper.properties   # 确认已指向腾讯镜像
cd ..
  • 原因:首次构建要下载 Gradle 发行版(一百多 MB),默认源 services.gradle.org 国内极慢。每个新项目都要改一次(这是项目内文件,不随环境走)。
  • 问题:
    • 忘了改 → npm run android 长时间卡在 Downloading gradle-x.x.x,Ctrl+C 后改好重跑即可(已下载部分有缓存)。
    • 改错了 URL(版本号对不上)→ wrapper 报 404,核对镜像路径里的版本号与原文件完全一致。

3、启动模拟器

# 方式 A(小白推荐):Android Studio → Device Manager → ▶
# 方式 B:命令行
emulator -avd 你的AVD名
  • 原因:npm run android 不会自动帮你开模拟器(iOS 的 run-ios 会),Android 侧必须先有在线设备。
  • 问题:
    • adb devices 列表空 → 模拟器没开完(冷启动 10~60 秒),等它出现锁屏界面再执行 adb devices,显示 emulator-xxxx device(注意状态必须是 device,offline 还没就绪)。
    • 模拟器莫名自己关了 → 用命令行后台方式启动(防终端会话结束连坐):
    • nohup emulator -avd 你的AVD名 > /tmp/emulator.log 2>&1 &

4、首次构建与安装

cd ~/rn-playground/RnAndroidDemo
npm run android
  • 方式:一条命令完成「编译 Gradle 插件 → 构建 APK → adb 安装 → 拉起 App」。
  • 原因:Android 构建链是 Gradle(对比 iOS 是 Xcodebuild),理解这条命令分四步走,出问题才能定位在哪一段。
  • 问题(重要,都是实测):
    • 首次构建约 20 分钟完全正常:过程中会自动下载 NDK(约 600MB)、CMake、新版 Build-Tools,还要编译四套 CPU 架构的 C++ 代码。判断死活:ps aux | grep -i gradle | grep -v grep,CPU 不为 0 就是在干活。第二次起就是秒级~分钟级增量构建。
    • 构建报 JDK 相关错(Unsupported class file major version 等)→ 回步骤 1,JAVA_HOME 指向了非 17 的 JDK。
    • 构建报 SDK location not found → ANDROID_HOME 没生效。
    • 成功标志:BUILD SUCCESSFUL + Installed on 1 device。

5、★手动启动 Metro(Android 侧必踩的静默坑)

# 新开一个终端
cd ~/rn-playground/RnAndroidDemo
npm start
  • 原因:npm run android 正常会顺手开新终端窗口跑 Metro,但在很多终端环境下这一步静默失败(日志里只有一行小字 error Cannot start server in new windows because no terminal app was specified),结果是装了个"没有粮食的空壳 App"——必红屏。养成习惯:Metro 永远自己开专属终端跑,全程别关。
  • 验收:curl http://localhost:8081/status → packager-status:running。
  • 问题:
    • Port 8081 already in use → 上次的 Metro 没退:lsof -ti:8081 | xargs kill -9。

6、★★ 授权本地网络访问(Android 16 / API 36+ 模拟器专属,最大暗坑)

        如果你的模拟器系统是 Android 16(API 36+),App 会红屏报 Unable to load script / Make sure you're running Metro,但检查一切"正常"——执行这条:

adb shell appops set com.rnandroiddemo ACCESS_LOCAL_NETWORK allow

然后重启 App:

adb shell am force-stop com.rnandroiddemo
adb shell am start -n com.rnandroiddemo/.MainActivity
  • 原因:Android 16 新增「本地网络访问」权限(ACCESS_LOCAL_NETWORK)。RN Debug 包要从 10.0.2.2(模拟器访问宿主 Mac 的专用别名)加载 JS,这个地址被系统划为本地网络——未授权时访问被静默丢弃,表现为超时,无任何弹窗和报错。这是 2026 年新建模拟器默认 API 36+ 后出现的新坑,网上资料极少。
  • 判断你是否中招(可选,学排查思路):
# App 视角探测 Metro 是否在线:6 秒后返回 false 且 shell 手测网络是通的 = 中招
adb logcat -d | grep isMetroRunning | tail -1
# 模拟器 shell 手测(返回 HTTP/1.1 200 OK 说明网络本身没问题):
adb shell 'printf "GET /status HTTP/1.1\r\nHost: 10.0.2.2:8081\r\n\r\n" | nc -w 5 10.0.2.2 8081'
# 查权限授予状态:
adb shell dumpsys package com.rnandroiddemo | grep ACCESS_LOCAL_NETWORK
  • 问题:
    • 每装一个新 App / 重装 App 都要重新执行一次(按包名生效);Android 15 及以下模拟器无此坑。
    • 执行后没变化 → 确认包名写对(在 android/app/build.gradle 的 applicationId 里看),并确认 Metro 真的在跑(步骤 二、5 验收)。

7、处理「附近设备」权限弹窗

adb shell pm grant com.rnandroiddemo android.permission.BLUETOOTH_SCAN
  • 原因:RN 新模板依赖带的蓝牙权限在 Android 12+ 是运行时权限,App 每次启动都弹「允许查找并连接到附近设备?」,弹窗期间 App 不继续加载 bundle,极易误判成"App 坏了"。
  • 问题:就算点过 Don't allow 下次还弹(模板每次启动都请求),pm grant 一次永久清净。开发用不上蓝牙,放心授。

✅ 阶段验收:模拟器显示 RN 欢迎页(Welcome to React Native);改 App.tsx 保存秒级热更新;adb logcat -d | grep ReactNativeJS | tail -1 能看到 Running "RnAndroidDemo"。

三、Demo 功能页面搭建

1、安装导航与存储依赖

npm install @react-navigation/native @react-navigation/native-stack \
            @react-navigation/bottom-tabs \
            react-native-screens \
            react-native-safe-area-context \
            @react-native-async-storage/async-storage
# 这些含原生代码,但 Android 侧 autolinking 自动处理,无需像 iOS 那样手动 pod install!
  • 原因:React Navigation 全家桶 + AsyncStorage(同 iOS 篇)。Android 与 iOS 的关键差异:RN 在 Android 用 autolinking(Gradle 自动扫描 node_modules 里的原生库),不需要手动执行任何"原生依赖安装"命令——这是 Android 侧比 iOS 省心的一点。
  • 问题:
    • 新装原生库后构建报 Could not resolve ... 或运行红屏缺模块 → 先彻底重建:
    • cd android && ./gradlew clean && cd ..
      npm run android
    • Gradle 偶发缓存抽风(报错与新增依赖相关的诡异错)→ cd android && ./gradlew --stop && rm -rf .gradle build app/build && cd .. 再跑。

2、导航骨架(与 iOS 同构,贴 Android 配色)

// src/navigation.tsx
import { NavigationContainer, DarkTheme } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { Text } from 'react-native'
import HomeScreen from './screens/HomeScreen'
import DetailScreen from './screens/DetailScreen'
import ProfileScreen from './screens/ProfileScreen'
import type { User } from './types'

export type RootStackParamList = {
  Home: undefined
  Detail: { user: User }
}
export type MainTabParamList = { 首页: undefined; 我的: undefined }

const Stack = createNativeStackNavigator<RootStackParamList>()
const Tab = createBottomTabNavigator<MainTabParamList>()

function HomeStack() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Home" component={HomeScreen} options={{ title: '用户列表' }} />
      <Stack.Screen name="Detail" component={DetailScreen} options={{ title: '用户详情' }} />
    </Stack.Navigator>
  )
}

export default function AppNav() {
  return (
    <NavigationContainer theme={DarkTheme}>
      <Tab.Navigator screenOptions={{ tabBarActiveTintColor: '#4F9CF9', headerShown: false }}>
        <Tab.Screen name="首页" component={HomeStack}
          options={{ tabBarIcon: () => <Text style={{ fontSize: 18 }}>👥</Text> }} />
        <Tab.Screen name="我的" component={ProfileScreen}
          options={{ tabBarIcon: () => <Text style={{ fontSize: 18 }}>👤</Text>, headerShown: true, headerTitle: '我的' }} />
      </Tab.Navigator>
    </NavigationContainer>
  )
}
// App.tsx
import AppNav from './src/navigation'
export default function App() {
  return <AppNav />
}
// src/types.ts
export interface User {
  id: number
  name: string
  email: string
  phone: string
  website: string
  company: { name: string }
}
  • 原因:结构说明同 iOS 篇(Tab 套 Stack、泛型 ParamList 类型安全)。Android 细节:底部 Tab 的样式由系统 Material 规范渲染,按压有原生 ripple 水波纹——这是 Android 平台默认行为,无需额外配置。
  • 问题:红屏缺原生模块 → 步骤 12 的 clean 重建。

3、列表页(网络请求 + 三态 + FlatList + 下拉刷新)

// src/screens/HomeScreen.tsx —— 与 iOS 版完全一致(这正体现 RN 跨端价值)
import { useState, useCallback } from 'react'
import { View, Text, FlatList, TouchableOpacity, RefreshControl, ActivityIndicator, StyleSheet } from 'react-native'
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
import type { RootStackParamList } from '../navigation'
import type { User } from '../types'

type Props = NativeStackScreenProps<RootStackParamList, 'Home'>

export default function HomeScreen({ navigation }: Props) {
  const [users, setUsers] = useState<User[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  const load = useCallback(async () => {
    try {
      setError(null)
      if (users.length === 0) setLoading(true)
      const res = await fetch('https://jsonplaceholder.typicode.com/users')
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      setUsers(await res.json())
    } catch (e) {
      setError(e instanceof Error ? e.message : '网络异常')
    } finally {
      setLoading(false)
    }
  }, [users.length])

  if (loading)
    return <View style={s.center}><ActivityIndicator size="large" /><Text>加载中…</Text></View>
  if (error)
    return (
      <View style={s.center}>
        <Text style={s.err}>{error}</Text>
        <TouchableOpacity style={s.btn} onPress={load}><Text>重试</Text></TouchableOpacity>
      </View>
    )
  return (
    <FlatList
      data={users}
      keyExtractor={(item) => String(item.id)}
      refreshControl={<RefreshControl refreshing={false} onRefresh={load} />}
      renderItem={({ item }) => (
        <TouchableOpacity style={s.card} onPress={() => navigation.navigate('Detail', { user: item })}>
          <Text style={s.name}>{item.name}</Text>
          <Text style={s.sub}>{item.company.name}</Text>
        </TouchableOpacity>
      )}
    />
  )
}

const s = StyleSheet.create({
  center: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
  err: { color: '#ff6b6b' },
  btn: { padding: 10, backgroundColor: '#4F9CF9', borderRadius: 8 },
  card: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#333' },
  name: { fontSize: 16, fontWeight: '600', color: '#fff' },
  sub: { fontSize: 13, color: '#888', marginTop: 4 },
})
  • 原因:这份代码与 iOS 版零差异可直接复用——RN "Learn once, write anywhere" 的实证;面试可强调:FlatList 视口回收(对应 Android RecyclerView)、三态设计、useCallback 防重渲染。
  • 问题(Android 特有):
    • Network request failed → 模拟器网络继承 Mac,但 Android 侧多一层坑:若宿主 Metro 不可达(步骤 9/10 的坑),fetch 请求也会失败——Android 上"网络请求失败"有时根因是本地网络权限而不是网络。
    • 列表底部被导航栏遮挡 → FlatList 加 contentContainerStyle={{ paddingBottom: 16 }}。

4、详情页(参数传递 + ★物理返回键拦截 BackHandler)

// src/screens/DetailScreen.tsx
import { useEffect } from 'react'
import { View, Text, ScrollView, TouchableOpacity, BackHandler, Linking, Alert, StyleSheet } from 'react-native'
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
import type { RootStackParamList } from '../navigation'

type Props = NativeStackScreenProps<RootStackParamList, 'Detail'>

export default function DetailScreen({ route, navigation }: Props) {
  const { user } = route.params

  // ★ Android 特色:拦截物理返回键
  useEffect(() => {
    const sub = BackHandler.addEventListener('hardwareBackPress', () => {
      Alert.alert('确认离开', '确定要返回列表页吗?', [
        { text: '留下', style: 'cancel' },
        { text: '返回', style: 'destructive', onPress: () => navigation.goBack() },
      ])
      return true   // ★ 返回 true 表示"已消费",阻止默认行为;false/不返回则执行默认返回
    })
    return () => sub.remove()   // ★ 卸载时必须移除,否则回到列表页返回键仍被拦截(高频内存泄漏点)
  }, [navigation])

  const Row = ({ label, value }: { label: string; value: string }) => (
    <View style={s.row}>
      <Text style={s.label}>{label}</Text>
      <Text style={s.value}>{value}</Text>
    </View>
  )
  return (
    <ScrollView contentContainerStyle={s.wrap}>
      <View style={s.avatar}><Text style={s.avatarText}>{user.name[0]}</Text></View>
      <Text style={s.name}>{user.name}</Text>
      <Row label="公司" value={user.company.name} />
      <Row label="邮箱" value={user.email} />
      <Row label="电话" value={user.phone} />
      <Row label="网站" value={user.website} />
      <TouchableOpacity style={s.btn} onPress={() => Linking.openURL(`mailto:${user.email}`)}>
        <Text style={s.btnText}>发邮件</Text>
      </TouchableOpacity>
    </ScrollView>
  )
}

const s = StyleSheet.create({
  wrap: { padding: 20 },
  avatar: { width: 72, height: 72, borderRadius: 36, backgroundColor: '#4F9CF9', alignSelf: 'center', justifyContent: 'center' },
  avatarText: { fontSize: 30, color: '#fff', textAlign: 'center' },
  name: { fontSize: 22, fontWeight: '700', textAlign: 'center', marginVertical: 16, color: '#fff' },
  row: { flexDirection: 'row', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#333' },
  label: { width: 60, color: '#888' },
  value: { flex: 1, color: '#fff' },
  btn: { marginTop: 24, padding: 14, backgroundColor: '#4F9CF9', borderRadius: 10, alignItems: 'center' },
  btnText: { color: '#fff', fontWeight: '600' },
})
  • 原因(面试高频):
    • BackHandler 是 Android 独有 API(iOS 没有物理返回键,手势返回由 native-stack 原生处理)。返回 true = 拦截、false = 放行默认行为,经典场景就是"表单未保存拦截返回"。
    • return () => sub.remove():不清理的话,离开页面后监听器仍挂着(闭包持有 navigation),列表页按返回键会再次弹确认框——事件监听泄漏是 RN Android 面试必问。
  • 问题:
    • 真机上返回键直接退出没弹窗 → 监听器返回了 false 或 undefined(默认放行),确认写的是 return true。
    • iOS 上跑这份代码 → hardwareBackPress 静默不触发(平台专属事件),无需做平台判断,非目标平台自动失效。

5、我的页(★运行时权限申请 PermissionsAndroid + 持久化)

// src/screens/ProfileScreen.tsx
import { useState, useEffect } from 'react'
import { View, Text, TextInput, Switch, Button, StyleSheet, Alert, PermissionsAndroid, Platform } from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'

const KEY = 'profile'

// ★ Android 特色:运行时权限申请(危险权限需弹窗征求用户同意)
async function requestNotifyPermission(): Promise<boolean> {
  // Android 13+ 通知属于运行时权限;低版本自动授权
  if (Platform.OS !== 'android' || Platform.Version < 33) return true
  const res = await PermissionsAndroid.request(
    PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
    {
      title: '通知权限',
      message: '开启后才能收到消息提醒',
      buttonPositive: '开启',
      buttonNegative: '取消',
    },
  )
  return res === PermissionsAndroid.RESULTS.GRANTED
}

export default function ProfileScreen() {
  const [nickname, setNickname] = useState('')
  const [notify, setNotify] = useState(true)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    AsyncStorage.getItem(KEY)
      .then((raw) => { if (raw) { const d = JSON.parse(raw); setNickname(d.nickname); setNotify(d.notify) } })
      .finally(() => setLoading(false))
  }, [])

  const toggleNotify = async (v: boolean) => {
    if (v) {
      const granted = await requestNotifyPermission()
      if (!granted) { Alert.alert('未授权', '没有通知权限,无法开启'); return }
    }
    setNotify(v)
  }

  const save = async () => {
    await AsyncStorage.setItem(KEY, JSON.stringify({ nickname, notify }))
    Alert.alert('已保存', `昵称:${nickname || '(空)'},通知:${notify ? '开' : '关'}`)
  }

  if (loading) return null
  return (
    <View style={s.wrap}>
      <Text style={s.title}>偏好设置</Text>
      <Text style={s.label}>昵称</Text>
      <TextInput style={s.input} value={nickname} onChangeText={setNickname}
        placeholder="输入昵称" placeholderTextColor="#666" />
      <View style={s.row}>
        <Text style={s.label}>接收通知</Text>
        <Switch value={notify} onValueChange={toggleNotify} />
      </View>
      <Button title="保存" onPress={save} color="#4F9CF9" />
      <Text style={s.hint}>首次开启通知会弹系统权限框;数据存本地(AsyncStorage),杀 App 重开仍在</Text>
    </View>
  )
}

const s = StyleSheet.create({
  wrap: { flex: 1, padding: 20 },
  title: { fontSize: 22, fontWeight: '700', marginBottom: 20, color: '#fff' },
  label: { color: '#aaa', marginBottom: 6 },
  input: { borderWidth: 1, borderColor: '#444', borderRadius: 8, padding: 12, color: '#fff', marginBottom: 20 },
  row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
  hint: { marginTop: 16, color: '#666', fontSize: 12 },
})
  • 原因(面试高频):
    • Android 权限模型:普通权限(如 INTERNET)装时自动授予;危险权限(相机/定位/通知等)必须运行时弹窗征求同意。PermissionsAndroid.request 是 RN 官方申请入口——这是与 iOS(Info.plist 声明 + 系统首次自动弹)最大的流程差异。
    • Platform.OS / Platform.Version 平台分支:跨端代码的标准写法,面试常问"怎么写平台差异化代码"。
    • AsyncStorage 持久化与受控组件说明同 iOS 篇。
  • 问题:
    • 弹窗不出现直接失败 → 模拟器 API < 13 不适用(代码已用 Version 守卫);或该权限曾被用户永久拒绝(选了"不再询问")——request 直接返回 never_ask_again,此时应引导去系统设置:Linking.openSettings()。
    • AndroidManifest 没声明权限会闪退:PermissionsAndroid 申请的权限必须在 android/app/src/main/AndroidManifest.xml 里有对应 <uses-permission>(通知权限模板已内置;其他权限如相机要自己加)——清单声明是"资格",运行时申请是"使用",缺一不可。

6、运行验证

# 模拟器在跑、Metro 在跑的前提下
npm run android

✅ 终极验收清单:

  1. 底部双 Tab 正常切换,按压有原生水波纹
  2. 列表自动加载 10 个用户,下拉出现刷新指示器(Android 是圆形转圈)
  3. 进详情页 → 按模拟器侧边的返回键(或 Esc 键)→ 弹出"确认离开"框,选"返回"回列表
  4. 我的页:开通知开关 → 弹系统权限框 → 允许后开关生效
  5. 改昵称保存 → 模拟器上划杀掉 App 重开 → 数据还在
  6. 改代码保存秒级热更新

四、调试能力(Android 模拟器专属)

操作方式说明
打开 RN 调试菜单模拟器获得焦点按 ⌘ + M(或 Ctrl+M)Android 对应 iOS 的 ⌘D
重新加载 JS调试菜单 → Reload(或终端 Metro 按 r)
查看 console.log终端 Metro 窗口 或 adb logcat -s ReactNativeJSlogcat 是 Android 日志总闸,-s ReactNativeJS 只看 RN JS 层日志——排查 JS 问题第一现场
调试 JS 断点调试菜单 → Open DebuggerHermes 原生调试器(Chrome DevTools 协议)
检查 UI 布局调试菜单 → Toggle Inspector
录屏/截图adb exec-out screencap -p > /tmp/x.png给同事/面试演示素材
  • 问题:
    • ⌘M 没反应 → 焦点不在模拟器窗口;或用 adb shell input keyevent 82(菜单键)代打。
    • logcat 噪音巨大 → 必须 -s 过滤标签,或 adb logcat -d --pid=$(adb shell pidof com.rnandroiddemo) 按进程过滤。
    • 热更新失效 → Metro 死了(重跑 npm start)或 watchman 故障(watchman shutdown-server 后重装)。

附录 A:Android 真机调试(比 iOS 简单得多)

  1. 手机 → 设置 → 关于手机 → 连点「版本号」7 次开启开发者模式 → 开发者选项 → 打开 USB 调试
  2. 数据线连 Mac(弹"允许 USB 调试"选允许)→ adb devices 显示设备
  3. ★关键一步(真机必需):adb reverse tcp:8081 tcp:8081,然后照常 npm run android(或直接在手机上打开已装的 App)
  4. 无线调试(可选):开发者选项 → 无线调试 → adb pair IP:端口 配对后 connect
  • 原因:真机的 localhost 是手机自己,不是你的 Mac。adb reverse 把"手机 8081 端口"反向代理到"电脑 8081",Metro 才够得着——不执行这条,真机上必红屏 Unable to load script(模拟器不需要,它走 10.0.2.2)。真机(Android 16)同样可能需要步骤 10 的 ACCESS_LOCAL_NETWORK 授权——访问的宿主机局域网 IP 也属本地网络。
  • 问题:
    • adb devices 显示 unauthorized → 手机上没点允许弹窗,重新插拔。
    • 显示设备但装不上 → 小米/华为等国产 ROM 有"USB 安装"独立开关,去开发者选项打开。

附录 B:Android 侧高频报错速查(实战浓缩)

报错/现象根因一条命令/操作
构建卡 Downloading gradlegradle.org 慢wrapper 换腾讯镜像
Unsupported class file major versionJDK 不是 17JAVA_HOME 指向 openjdk@17
SDK location not foundANDROID_HOME 未生效source ~/.zshrc 重开终端
Installed on 0 devices / 构建后没反应模拟器没开/没就绪adb devices 确认状态为 device
红屏 Unable to load script(Metro 明明在跑)Android 16 本地网络权限(API 36+)adb shell appops set 包名 ACCESS_LOCAL_NETWORK allow 后重启 App
Metro 根本没起CLI 静默失败自己开终端 npm start,curl localhost:8081/status 验证
每次 App 启动弹"附近设备"权限BLUETOOTH_SCAN 运行时权限adb shell pm grant 包名 android.permission.BLUETOOTH_SCAN
真机红屏连不上 Metro缺端口反向代理adb reverse tcp:8081 tcp:8081
新装原生库后构建/红屏异常Gradle 缓存旧cd android && ./gradlew clean 重建
Port 8081 already in use旧 Metro 未退lsof -ti:8081 | xargs kill -9
红屏 Text strings must be rendered…裸文本没包 <Text>所有文本包 <Text>(Web 转 RN 第一坑)
通知权限申请直接失败且不弹窗用户曾选"不再询问"引导 Linking.openSettings()

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐