基本功能完成

This commit is contained in:
2026-06-20 21:43:09 +08:00
parent 84590d5477
commit a89d23b43c
45 changed files with 3508 additions and 1261 deletions
+558
View File
@@ -0,0 +1,558 @@
# TriloopTem App 商业化审计报告
> 审计日期:2026-06-20
> 审计范围:代码质量、UX 健壮性、安全性、性能、数据完整性
> 总计问题:36 项(致命 4 / 高 13 / 中 14 / 低 5)
---
## 一、代码质量与架构
### 1.1 [致命] 生产代码中有大量调试日志
**文件及行号:**
- `src/services/TcpService.ts` — 第 38, 45, 51, 65, 68, 70, 75, 83, 125, 129 行(10 处)
- `src/services/DeviceService.ts` — 第 30, 111-114, 149, 153, 159 行(5 处)
- `src/protocol/parser.ts` — 第 58-61, 64, 71, 77 行(4 处)
**问题描述:**
总计约 20 处 `console.log` / `console.warn` 调用。其中 TcpService 第 51 和 68 行在每个 TCP data 事件上做 `Array.from(...).map(b => '0x' + b.toString(16))` 十六进制格式化,DeviceService 第 111-114 行在每帧数据到达时循环打印各通道的原始 ADC 值。在 250kHz 六通道连续采集时,这些日志每秒触发数百次,产生大量 GC 压力。
**修复方案:**
删除所有 `console.log` / `console.warn`,或用 `if (__DEV__)` 包裹。建议封装一个 `logger` 模块,在 release 构建中为 no-op。
---
### 1.2 [高] IP 地址和端口号缺少格式校验
**文件:** `src/components/modals/ConnectModal.tsx` 第 42-43 行
**问题描述:**
连接弹窗仅检查 `!host.trim()` 和 `isNaN(port)`,接受诸如 `999.999.999.999` 等无效 IP,端口号也没有范围限制(1-65535)。
**修复方案:**
在连接前用正则或 IP 解析函数校验地址格式,端口校验 `port >= 1 && port <= 65535`。
---
### 1.3 [高] 心跳写入失败被静默吞掉
**文件:** `src/services/TcpService.ts` 第 97-99 行
**问题描述:**
心跳定时器每 8 秒写 `'0'` 到 socket,外层 `try/catch` 捕获所有异常但不做任何处理。如果 socket 处于半断开状态,心跳持续失败但 `this.connected` 仍为 `true`,用户无法感知连接已断。
**修复方案:**
在 catch 中将 `this.connected` 设为 `false`,调用 `this.callbacks?.onError()`,触发重连流程。
---
### 1.4 [高] 数据库查询使用 `any` 类型
**文件:** `src/services/StorageService.ts` 第 247 行
**问题描述:**
`getAllAsync<any>` 绕过了 TypeScript 类型检查。如果后续数据库 schema 变更(列名改动),运行时会产生 `undefined` 值但编译阶段无法发现。
**修复方案:**
为每个查询定义明确的行类型接口,替换 `any`。
---
### 1.5 [中] TcpService 单例无防重复连接保护
**文件:** `src/services/TcpService.ts` 第 23-30 行
**问题描述:**
在已连接状态下调用 `connect()` 不会先断开旧连接。`createSocket()` 虽然销毁旧 socket,但旧的 reconnect 定时器可能仍在运行,回调引用也未清理。
**修复方案:**
在 `connect()` 开头调用 `this.disconnect()` 确保干净状态。
---
### 1.6 [中] 分帧传输状态使用模块级变量,断线后不重置
**文件:** `src/services/DeviceService.ts` 第 24-26 行
**问题描述:**
`splitChunks`、`splitActive`、`splitFrameNo` 是模块级 `let` 变量。如果分帧传输中途断线重连,这些状态不会重置,下次分帧传输会拼接到旧的脏数据上。
**修复方案:**
在 `onClose` 回调中重置分帧状态,或在 `deviceStartSplitFrame` 开始时清理。
---
### 1.7 [中] `clearTimeout` 对可能为 null 的值使用 `!` 断言
**文件:** `src/services/TcpService.ts` 第 43, 113, 137 行
**问题描述:**
`clearTimeout(this.reconnectTimer!)` 虽然在大多数 JS 引擎中对 null 不会崩溃,但 `!` 非空断言不正确,严格模式下可能隐藏问题。
**修复方案:**
改为 `if (this.reconnectTimer) clearTimeout(this.reconnectTimer)`。
---
### 1.8 [低] 导出函数缺少返回类型注解
**文件:** `src/services/DeviceService.ts` 第 28, 172-186 行
**问题描述:**
`handleIncomingFrame`、`deviceStartSplitFrame` 等公共 API 函数没有显式返回类型。
**修复方案:**
为所有 `export` 函数添加返回类型注解。
---
### 1.9 [低] filePrefix 默认值在模块加载时计算,跨天不更新
**文件:** `src/stores/deviceStore.ts` 第 29 行
**问题描述:**
`new Date().toISOString().slice(0,10).replace(/-/g,'')` 仅在模块首次加载时执行一次。如果 App 过了午夜仍在运行,文件前缀还是昨天的日期。
**修复方案:**
在创建新采集会话时动态生成日期前缀,或在每天零点时刷新。
---
## 二、UX 与健壮性
### 2.1 [致命] ACK 竞态条件——并发请求覆盖
**文件:** `src/services/DeviceService.ts` 第 147-170 行
**问题描述:**
`pendingAcks` 以 func code 为 key 存储 Promise resolver。如果用户快速连续点击(如双击"下发配置"按钮),第二次 `pendingAcks.set()` 会静默覆盖第一次的 resolver,第一个 Promise 永远不会 resolve,直到 5 秒超时才报错。
**修复方案:**
在覆盖前先 reject 已有的 pending promise,或使用队列/序列号机制。UI 层也应在 `busy` 状态下禁用按钮(已部分实现但需确认所有路径)。
---
### 2.2 [高] TCP 初始连接无超时
**文件:** `src/services/TcpService.ts` 第 32-91 行
**问题描述:**
`TcpSocket.createConnection` 没有连接超时设置。如果设备不可达,可能挂起 75+ 秒等待操作系统级 TCP 超时。期间 UI 显示"连接中"但无法取消。
**修复方案:**
添加 10 秒连接超时:如果在超时内未收到 `onConnect` 回调,销毁 socket 并调用 `onError`。可以在 `createSocket()` 中启动一个定时器,`onConnect` 时清除。
---
### 2.3 [高] clearHistory 只清内存不清数据库
**文件:** `src/stores/dataStore.ts` 第 62 行
**问题描述:**
`clearHistory` 仅清空内存中的 `history` 数组和 `currentFrame`,但 SQLite 中的帧数据和磁盘上的 bin 文件不受影响。用户以为"清除"了数据,实际上数据仍然存在。
**修复方案:**
要么连同数据库和文件一起删除,要么将按钮文案改为"清除显示"并向用户说明数据仍已保存。
---
### 2.4 [高] 分享功能不可用时无用户反馈
**文件:** `src/utils/export.ts` 第 94-99 行
**问题描述:**
`shareFile` 调用 `Sharing.isAvailableAsync()` 检测分享是否可用,如果不可用则静默返回。用户点击"导出"后看到 CSV 文件生成但什么也没发生。
**修复方案:**
在分享不可用时弹出 Alert 提示,或提供复制文件路径的选项。
---
### 2.5 [中] 操作前不检查连接状态
**文件:** `src/hooks/useDevice.ts` 第 35-78 行
**问题描述:**
`setup`、`startContinuous`、`startSingle` 在发送命令前不检查是否已连接。当未连接时,`sendAndWaitAck` 会立即 reject "TCP not connected",但用户看到的提示是"配置超时"而非"未连接"。
**修复方案:**
在每个操作开始时检查 `tcpService.isConnected()`,未连接时显示明确的"请先连接设备"提示。
---
### 2.6 [中] wave.tsx 项目名加载无错误处理
**文件:** `app/(tabs)/wave.tsx` 第 396-400 行
**问题描述:**
动态 import + 异步链没有 `.catch()` 处理。如果 `listProjects()` 失败,Promise rejection 未被捕获。
**修复方案:**
添加 `.catch(() => setProjectName(null))`。
---
### 2.7 [中] CSV 导出只导出内存中的帧
**文件:** `app/(tabs)/records.tsx` 第 113 行
**问题描述:**
`exportCsv(history, sessionId)` 只导出当前内存中的帧数据(最多 `MAX_HISTORY = 500` 帧)。如果用户采集了 1000 帧,CSV 中只有最近 500 帧,且用户不知情。
**修复方案:**
从数据库导出全部帧,或在导出前提示用户当前仅包含内存中的 N 帧。
---
### 2.8 [低] 大项目导出无进度指示
**文件:** `src/services/StorageService.ts` 第 336-393 行
**问题描述:**
`exportProject` 对大项目可能耗时较长(读取所有 bin 文件),虽然提供了 `onProgress` 回调,但调用方需确保显示进度 UI。
**修复方案:**
在项目导出时显示进度条或加载动画。
---
## 三、安全性
### 3.1 [高] 原始数据被打印到日志
**文件:**
- `src/services/TcpService.ts` 第 51, 68, 125 行
- `src/services/DeviceService.ts` 第 111-114 行
**问题描述:**
原始 TCP 载荷的十六进制内容和各通道 ADC 数值被打印到 console。在 Android 上,`console.log` 输出可通过 `adb logcat` 被任何持有 `READ_LOGS` 权限的应用读取。对于地球物理勘探数据,这可能涉及商业敏感信息。
**修复方案:**
同 1.1,移除或用 `__DEV__` 门控。
---
### 3.2 [中] 项目名/文件名未做文件系统安全过滤
**文件:** `src/services/StorageService.ts` 第 104-112, 390 行
**问题描述:**
`createProject` 使用 `name.trim()` 但不过滤文件系统特殊字符。`exportProject` 第 390 行直接将项目名拼入文件路径 `TEM_${proj.name}_${Date.now()}.tem`。包含 `/`、`\`、`..` 或空字节的项目名可能导致路径穿越或文件创建失败。
**修复方案:**
对项目名做白名单过滤(只允许字母、数字、中文、下划线、短横线),或在文件名中使用项目 ID 代替名称。
---
### 3.3 [中] GPS 坐标明文存储
**文件:** `src/services/StorageService.ts` 第 58-69 行
**问题描述:**
GPS 经纬度、海拔以明文存储在 SQLite 中,zustand 持久化文件也是明文 JSON。
**修复方案:**
评估是否需要静态加密。如果需要,可使用 `expo-crypto` 或 SQLCipher。对于大多数行业仪器 App 这不是硬性要求,但需在隐私政策中声明。
---
### 3.4 [低] filePrefix 输入未做字符过滤
**文件:** `src/components/ParamForm.tsx` 第 183 行
**问题描述:**
`filePrefix` 只限制了长度(15 字符)但未过滤特殊字符。该字段仅用于协议包构建(不进 SQL),风险较低。
**修复方案:**
添加正则校验,只允许字母、数字、下划线。
---
## 四、性能
### 4.1 [致命] 热路径上的重量级调试计算
**文件:**
- `src/services/TcpService.ts` 第 51, 68 行
- `src/services/DeviceService.ts` 第 111-114 行
**问题描述:**
每个 TCP data 事件都做 `Array.from(arr.slice(0, 20)).map(...)` 创建中间数组和字符串。每帧测量数据到达时循环各通道做 `Array.from()` + `.toFixed()`。连续采集时每秒触发数百次。
**修复方案:**
同 1.1,直接删除。
---
### 4.2 [高] Parser 缓冲区溢出时静默丢弃数据
**文件:** `src/protocol/parser.ts` 第 27-29 行
**问题描述:**
当 `this.len + incoming.length > capacity`(2MB)时,直接 `this.len = 0` 重置缓冲区,丢弃所有已缓存数据。无错误回调,无日志,用户无感知。在高吞吐量连续采集时可能导致数据丢失。
**修复方案:**
至少触发一个错误回调通知上层。考虑动态扩容或增大默认容量。记录溢出事件以便排查。
---
### 4.3 [高] 内存中历史帧可能导致 OOM
**文件:** `src/stores/dataStore.ts` 第 47 行
**问题描述:**
每个 `MeasurementFrame` 包含 `adcRaw: Int32Array[]` 和 `adcUV: Float64Array[]`。以 6 通道 2000 采样深度计算,每帧约 `6 × 2000 × (4 + 8) = 144 KB`。`MAX_HISTORY = 500` 时内存占用可达 **72 MB**。中低端手机可能 OOM 崩溃。
**修复方案:**
- 方案 A:将 `MAX_HISTORY` 降至 50
- 方案 B:内存中只存元数据和峰值摘要,完整波形按需从 bin 文件加载
- 方案 C:历史帧只保留 `adcUV`,不保留 `adcRaw`(节省 1/3 内存)
---
### 4.4 [中] 缩放/拖拽时每帧重建 Skia Path
**文件:** `src/components/WaveformChart.tsx` 第 204-222 行
**问题描述:**
`channelPaths` 的 `useMemo` 依赖 `toY`、`xMinMs`、`xMaxMs`,这些在每次手势更新时都会变化。每个手势帧都触发全量重新计算所有通道的 Skia Path(最多 512 点/通道 × 6 通道)。
**修复方案:**
在固定坐标系中构建 Path,用 Skia 矩阵变换实现平移缩放,避免逐帧重建 Path。
---
### 4.5 [中] 降采样使用简单抽取,可能遗漏尖峰
**文件:** `src/hooks/useWaveform.ts` 第 15-22 行
**问题描述:**
当前降采样每隔 N 个取一个点。对于测量仪器来说,这可能完全遗漏瞬态尖峰信号,导致显示的峰值与实际不符。
**修复方案:**
使用 min-max 降采样(每组取最大值和最小值各一个点)或 LTTB 算法,确保极值被保留。
---
### 4.6 [中] PeakRow 每次渲染都重新计算峰值
**文件:** `app/(tabs)/wave.tsx` 第 294-297 行
**问题描述:**
`ch.reduce((m, v) => Math.max(m, Math.abs(v)), 0)` 在每次组件渲染时对每个可见通道运行一次。对于大数组(2000+ 点)这是不必要的开销。
**修复方案:**
在帧接收时计算峰值并缓存到 `MeasurementFrame`,或用 `useMemo` 包裹。
---
### 4.7 [低] Base64 编码产生大量中间字符串
**文件:** `src/services/BinLoader.ts` 第 68-75 行
**问题描述:**
`u8ToBase64` 将二进制数据拆成 8192 字节块,逐块 `String.fromCharCode` 再 `btoa`。6 通道 2000 点的帧约 48KB 二进制 → 64KB base64 字符串。批量导出时内存抖动明显。
**修复方案:**
考虑使用 `expo-file-system` 的直接二进制写入能力,或流式处理。
---
## 五、数据完整性
### 5.1 [致命] importProject 中 gain 列写入了错误值
**文件:** `src/services/StorageService.ts` 第 480-484 行
**问题描述:**
SQL INSERT 的 `gain` 参数位置(第 10 个参数)使用了 `meta.ampRatio`(增益 code,如 3 代表 1×),而不是实际增益倍数(`AMP_GAIN[meta.ampRatio]`,即 1)。导致导入的数据在重新加载时,电压换算使用错误的增益值,波形幅度错误。
**修复方案:**
第 483 行改为 `AMP_GAIN[meta.ampRatio] ?? 1`。
---
### 5.2 [高] 数据库迁移无版本管理
**文件:** `src/services/StorageService.ts` 第 73-78 行
**问题描述:**
当前通过 `try { ALTER TABLE } catch {}` 添加 `project_id` 列。这种方式只能处理单次迁移,随着 App 迭代新增更多 schema 变更时无法可靠管理。没有版本号追踪,无法知道数据库处于什么状态。
**修复方案:**
实现基于 `PRAGMA user_version` 的迁移系统:
```typescript
const version = await db.getFirstAsync<{user_version:number}>('PRAGMA user_version');
if (version.user_version < 1) {
await db.runAsync('ALTER TABLE sessions ADD COLUMN project_id TEXT');
await db.runAsync('PRAGMA user_version = 1');
}
if (version.user_version < 2) {
// 下一次迁移...
await db.runAsync('PRAGMA user_version = 2');
}
```
---
### 5.3 [高] 删除项目不级联删除关联数据
**文件:** `src/services/StorageService.ts` 第 139-142 行
**问题描述:**
`deleteProject` 只删除 `projects` 表中的记录。关联的 sessions(`project_id` 被 SET NULL)、frames 记录和磁盘上的 bin 文件都成为孤儿数据,永远不会被清理。
**修复方案:**
删除项目时,先查出关联的 sessions,再删除对应的 frames 和 bin 文件,最后删除 sessions 和 project 记录。
---
### 5.4 [高] persistFrame 使用 INSERT OR REPLACE 可能静默覆盖数据
**文件:** `src/services/StorageService.ts` 第 224 行
**问题描述:**
如果由于 bug 或竞态条件,两帧具有相同的 `(frameId, sessionId)` 主键,第二帧会静默覆盖第一帧的数据,无任何警告。
**修复方案:**
改用 `INSERT OR IGNORE` 并记录警告日志,或在插入前检查唯一性。
---
### 5.5 [中] Session/Project ID 可能冲突
**文件:**
- `src/stores/dataStore.ts` 第 113-119 行
- `src/services/StorageService.ts` 第 95-101 行
**问题描述:**
`generateSessionId` 和 `generateProjectId` 使用秒级时间戳生成 ID。如果在同一秒内创建两个会话或项目,ID 会重复。`ensureSession` 使用 `INSERT OR IGNORE`,第二个会话会静默复用第一个。
**修复方案:**
添加随机后缀(如 4 位随机字母数字),或使用 UUID,或至少使用毫秒精度。
---
### 5.6 [中] CSV 导出未转义特殊字符
**文件:**
- `src/utils/export.ts` 第 28-47 行
- `src/services/StorageService.ts` 第 318-327 行
**问题描述:**
CSV 值直接用逗号拼接,未做引号包裹或转义。虽然数值字段一般不含逗号,但如果 UTC 字符串在某些 locale 下包含逗号,CSV 格式会错乱。
**修复方案:**
对所有字段做标准 CSV 转义:包含逗号、引号或换行的值用双引号包裹,值内的双引号用两个双引号转义。
---
### 5.7 [中] 帧计数器内存与数据库可能不同步
**文件:** `src/stores/dataStore.ts` 第 38-41 行
**问题描述:**
`nextFrameId` 在 zustand 内存中递增,`persistFrame` 通过 `void` 异步调用(fire-and-forget)。如果 `persistFrame` 失败(如磁盘满),内存中计数器继续递增但数据库缺少对应帧,间隙永远不会被检测到。
**修复方案:**
`await persistFrame` 或至少处理其 rejection(记录错误,通知用户,重试)。
---
### 5.8 [中] 导入项目时临时文件清理为 fire-and-forget
**文件:** `src/services/StorageService.ts` 第 430 行
**问题描述:**
`FileSystem.deleteAsync(tmpPath, ...).catch(() => {})` 静默忽略清理失败。多次失败的导入会在设备上累积临时文件。
**修复方案:**
跟踪临时文件并定期清理,或 await 删除操作并在失败时记录。
---
### 5.9 [低] parseAck 读取 reason 字符串时未检查 payload 长度
**文件:** `src/protocol/parser.ts` 第 189-195 行
**问题描述:**
reason 字符串从 payload offset 22 开始读取,但没有检查 `payload.length >= 22`。如果设备返回一个短 ACK 包,循环会读取到 `undefined`,`String.fromCharCode(undefined)` 得到乱码。
**修复方案:**
在读取前检查 `if (payload.length < reasonOffset) return { result, reason: '' }`。
---
## 修复状态追踪
### ✅ 已修复
| # | 问题 | 修复日期 |
|---|------|---------|
| 1.1 | 调试日志加 `__DEV__` 门控 | 2026-06-20 |
| 1.2 | IP/端口格式校验 | 2026-06-20 |
| 1.3 | 心跳失败触发重连 | 2026-06-20 |
| 1.5 | connect 防重复连接 | 2026-06-20 |
| 1.6 | 分帧状态断线重置 | 2026-06-20 |
| 1.9 | filePrefix 动态日期 | 2026-06-20 |
| 2.1 | ACK 竞态覆盖保护 | 2026-06-20 |
| 2.2 | TCP 连接 10s 超时 | 2026-06-20 |
| 2.3 | clearHistory 文案明确 | 2026-06-20 |
| 2.4 | 分享不可用提示 | 2026-06-20 |
| 2.5 | 操作前检查连接 | 2026-06-20 |
| 2.6 | 项目名加载加 catch | 2026-06-20 |
| 3.1 | 日志泄露(同 1.1) | 2026-06-20 |
| 3.2 | 项目名文件安全过滤 | 2026-06-20 |
| 4.1 | 热路径调试计算(同 1.1) | 2026-06-20 |
| 4.2 | Parser 溢出加 onOverflow 回调 | 2026-06-20 |
| 4.3 | MAX_HISTORY 降至 50 | 2026-06-20 |
| 4.5 | 降采样改 min-max 保留极值 | 2026-06-20 |
| 4.6 | PeakRow 已删除 | 2026-06-20 |
| 5.1 | importProject gain 用 AMP_GAIN | 2026-06-20 |
| 5.2 | 数据库迁移 PRAGMA user_version | 2026-06-20 |
| 5.3 | 删项目级联清理文件 | 2026-06-20 |
| 5.4 | INSERT OR REPLACE → IGNORE | 2026-06-20 |
| 5.5 | ID 加毫秒+随机后缀 | 2026-06-20 |
| 5.9 | parseAck 长度检查 | 2026-06-20 |
### ⏳ 暂不修(低风险或需大重构)
| # | 问题 | 原因 |
|---|------|------|
| 1.4 | 数据库查询 `any` 类型 | 纯代码规范 |
| 1.7 | clearTimeout `!` 断言 | 运行无影响 |
| 1.8 | 缺返回类型注解 | 纯代码规范 |
| 2.7 | CSV 只导内存帧 | 需重构导出 |
| 2.8 | 大项目导出无进度 | UX 优化级 |
| 3.3 | GPS 明文存储 | 行业仪器通常不要求 |
| 3.4 | filePrefix 字符过滤 | 仅用于协议包 |
| 4.4 | 缩放重建 Path | 性能优化级 |
| 4.7 | Base64 中间字符串 | 优化级 |
| 5.6 | CSV 未转义 | 纯数值低风险 |
| 5.7 | 帧计数不同步 | 需重构存储层 |
| 5.8 | 临时文件清理 | 低风险 |
### 原修复优先级建议(已完成)
~~### 第一批(发布前必须修复)~~
1. ~~移除所有调试日志(1.1 + 3.1 + 4.1)~~
2. ~~修复 importProject gain 字段(5.1)~~
3. ~~降低内存历史上限或改为按需加载(4.3)~~
4. ~~修复 ACK 竞态条件(2.1)~~
~~### 第二批(发布前应该修复)~~
5. ~~添加 TCP 连接超时(2.2)~~
6. ~~心跳失败处理(1.3)~~
7. 操作前检查连接状态(2.5)
8. 数据库迁移版本管理(5.2)
9. 删除项目级联清理(5.3)
10. IP/端口校验(1.2)
11. 项目名文件安全过滤(3.2)
12. 分享不可用提示(2.4)
13. Parser 缓冲区溢出通知(4.2)
### 第三批(后续迭代优化)
14. Skia 矩阵变换优化波形缩放(4.4)
15. Min-max 降采样算法(4.5)
16. Session ID 防冲突(5.5)
17. CSV 转义(5.6)
18. 其余低优先级项
+434
View File
@@ -0,0 +1,434 @@
# EAS 云构建指南
> 适用于 TriloopTem App (TEM Receiver)
> 最后更新:2026-06-20
本文档详细说明如何使用 Expo EAS 云构建服务编译 Android APK 和 iOS IPA,包括所有账号注册和配置步骤。
---
## 一、前置准备
### 1.1 所需账号
| 账号 | 用途 | 费用 |
|------|------|------|
| Expo 账号 | EAS 云构建平台 | 免费(30 次/月构建额度) |
| Google 开发者账号 | 上架 Google Play(可选) | $25 一次性 |
| Apple Developer Program | iOS 签名和分发(必须) | ¥688/年 |
### 1.2 本地环境
```
Node.js >= 18
npm >= 9
Git(代码需在 Git 仓库中)
```
---
## 二、注册 Expo 账号
1. 访问 https://expo.dev/signup
2. 填写用户名、邮箱、密码,完成注册
3. 验证邮箱
---
## 三、注册 Apple Developer Program(iOS 必须)
### 3.1 注册 Apple ID
如果没有 Apple ID:
1. 访问 https://appleid.apple.com/account
2. 填写姓名、邮箱、手机号
3. 完成双重认证设置
### 3.2 加入 Apple Developer Program
1. 访问 https://developer.apple.com/programs/enroll/
2. 点击「开始注册」
3. 选择注册类型:
- **个人** — 适合个人开发者,只需 Apple ID
- **组织** — 需要 DUNS 编号(邓白氏编码),适合公司
4. 填写个人/公司信息
5. 支付年费 **¥688**(支持 Visa/MasterCard/银联)
6. 等待审核(通常 24-48 小时)
### 3.3 审核通过后
- 登录 https://developer.apple.com 确认会员状态为 Active
- 记住你的 **Team ID**(在 Membership 页面可以看到)
---
## 四、安装和配置 EAS CLI
### 4.1 安装
```bash
npm install -g eas-cli
```
### 4.2 登录 Expo 账号
```bash
eas login
```
输入注册的 Expo 用户名和密码。
### 4.3 验证登录
```bash
eas whoami
```
显示你的用户名即为成功。
---
## 五、项目配置
### 5.1 确认 app.json
确保 `app.json` 中以下字段已正确填写:
```json
{
"expo": {
"name": "TEM Receiver",
"slug": "TriloopTem_App",
"version": "1.0.0",
"ios": {
"bundleIdentifier": "com.triloop.temreceiver"
},
"android": {
"package": "com.triloop.temreceiver"
}
}
}
```
### 5.2 初始化 EAS 配置
```bash
cd f:\1_Projects\2026\SixChannelApp\TriloopTem_App
eas build:configure
```
自动生成 `eas.json` 文件。手动编辑为以下内容:
```json
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
},
"ios": {
"simulator": true
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "apk"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "你的Apple ID邮箱",
"ascAppId": "App Store Connect中的App ID(上架时填写)"
}
}
}
}
```
### 5.3 确保代码在 Git 仓库中
EAS 需要 Git 仓库:
```bash
git add -A
git commit -m "准备 EAS 构建"
```
---
## 六、构建 Android
### 6.1 Preview 版(内部测试用 APK)
```bash
eas build --platform android --profile preview
```
首次构建会提示:
- **Generate a new Android Keystore?** → 选 **Yes**(EAS 自动管理签名密钥)
构建过程(约 10-20 分钟):
1. 代码上传到 EAS 云端
2. 云端安装依赖
3. 编译生成 APK
4. 提供下载链接
### 6.2 Production 版(正式发布用)
```bash
eas build --platform android --profile production
```
### 6.3 下载 APK
构建完成后终端会显示下载链接,也可以在 https://expo.dev 的项目页面查看。
```bash
# 查看构建列表
eas build:list --platform android
```
---
## 七、构建 iOS
### 7.1 首次构建
```bash
eas build --platform ios --profile production
```
首次构建会依次提示以下内容:
#### 第 1 步:Apple 账号登录
```
? Do you want to log in to your Apple account? › Yes
? Apple ID: › 输入你的 Apple ID 邮箱
? Password: › 输入密码
```
如果开启了双重认证,会要求输入 6 位验证码。
#### 第 2 步:选择开发者团队
```
? Select Team ›
❯ XXXXXXXX - 你的名字/公司名 (Team ID)
```
选择你的开发者团队。
#### 第 3 步:Bundle Identifier 确认
```
? Bundle Identifier: › com.triloop.temreceiver
```
直接回车使用默认值。
#### 第 4 步:签名证书管理
```
? How would you like to manage your credentials? ›
❯ Let Expo handle it (Recommended)
I want to provide my own
```
**选择 "Let Expo handle it"**。EAS 会自动:
- 创建 Distribution Certificate(发布证书)
- 创建 Provisioning Profile(配置文件)
- 安全存储在 EAS 服务器上
#### 第 5 步:等待构建
iOS 构建约 15-30 分钟。进度可在终端或 https://expo.dev 查看。
### 7.2 后续构建
第二次及以后的构建不再需要上述交互,直接运行:
```bash
eas build --platform ios --profile production
```
### 7.3 下载 IPA
```bash
eas build:list --platform ios
```
或访问 https://expo.dev 项目页面下载。
---
## 八、安装到设备
### 8.1 Android
下载 APK 后直接安装:
```bash
adb install app-xxx.apk
```
或将 APK 发送到手机上点击安装。
### 8.2 iOS — 内部测试(Ad Hoc)
**注意**:需要先注册测试设备的 UDID。
#### 注册测试设备
1. 手机 Safari 访问 https://expo.dev/register-device
2. 安装描述文件获取 UDID
3. 在 EAS 中注册设备:
```bash
eas device:create
```
按提示输入设备 UDID 和名称。
#### 构建 Ad Hoc 版本
```bash
eas build --platform ios --profile preview
```
构建完成后生成安装链接,在手机 Safari 中打开即可安装。
### 8.3 iOS — TestFlight 分发
适合分发给多人测试:
#### 提交到 App Store Connect
```bash
eas submit --platform ios
```
提示:
```
? Select a build to submit › 选择最近的构建
? Apple ID: › 你的 Apple ID
```
提交后:
1. 登录 https://appstoreconnect.apple.com
2. 进入「我的 App」→ 选择 App → TestFlight
3. 等待 Apple 审核(通常几分钟到几小时)
4. 审核通过后添加内部/外部测试人员
5. 测试人员在 TestFlight App 中安装
---
## 九、上架应用商店(可选)
### 9.1 Google Play
1. 注册 Google Play 开发者账号($25 一次性):https://play.google.com/console/signup
2. 在 Google Play Console 创建应用
3. 构建 AAB 格式:
修改 `eas.json` 的 production profile:
```json
"production": {
"android": {
"buildType": "app-bundle"
}
}
```
```bash
eas build --platform android --profile production
eas submit --platform android
```
### 9.2 App Store
1. 在 App Store Connect 创建 App:https://appstoreconnect.apple.com
2. 填写 App 信息(名称、描述、截图、分类等)
3. 提交构建:
```bash
eas build --platform ios --profile production
eas submit --platform ios
```
4. 在 App Store Connect 中选择构建版本
5. 提交审核(通常 24-48 小时)
---
## 十、常用命令速查
```bash
# ── 账号 ──
eas login # 登录
eas whoami # 查看当前账号
# ── 构建 ──
eas build -p android # 构建 Android(默认 production)
eas build -p ios # 构建 iOS
eas build -p all # 同时构建双平台
eas build -p android --profile preview # 指定 profile
eas build:list # 查看构建历史
eas build:cancel # 取消当前构建
# ── 设备管理(iOS Ad Hoc)──
eas device:create # 注册测试设备
eas device:list # 查看已注册设备
# ── 提交商店 ──
eas submit -p android # 提交到 Google Play
eas submit -p ios # 提交到 App Store
# ── 证书管理 ──
eas credentials # 管理签名证书
```
---
## 十一、常见问题
### Q: 构建失败,提示 "Missing credentials"
**A:** 运行 `eas credentials` 重新配置签名证书。
### Q: iOS 构建提示 "No bundle identifier"
**A:** 确认 `app.json` 中 `ios.bundleIdentifier` 已填写。
### Q: 免费额度用完了怎么办
**A:** 等下个月刷新(每月 30 次),或改用本地构建(Android 用 `build-release.bat`,iOS 需要 Mac)。
### Q: 不想用 EAS 管理证书,可以自己管理吗
**A:** 可以。首次构建时选 "I want to provide my own",然后上传你的 .p12 证书和 .mobileprovision 文件。
### Q: Android 签名密钥丢了怎么办
**A:** EAS 自动管理的密钥存储在 Expo 服务器,不会丢失。可以用 `eas credentials` 随时下载备份。
---
## 十二、费用总结
| 场景 | 费用 |
|------|------|
| 仅构建 Android APK | **免费**(EAS 免费额度) |
| 构建 iOS + 自己测试 | **¥688/年**(Apple Developer) |
| 上架 Google Play | **$25 一次性** + 免费构建 |
| 上架 App Store | **¥688/年**(Apple Developer) |
| 双平台上架 | **¥688/年 + $25** |