在 React Native 中接入 CloudBase AI(DeepSeek / 混元)
一句话定义:RN 0.74+ 移动端走「前端 fetch + 后端代理」两段式:后端 Route Handler(Next.js / 云函数 / 云托管任选)用
@cloudbase/node-sdk的app.ai().createModel('cloudbase').streamText({model: 'deepseek-v4-flash'})返回text/plain流式 Response;RN 前端用fetch + body.getReader()(或react-native-fetch-apipolyfill / XHRonprogressfallback)读流,FlatList 逐字渲染。SecretId / SecretKey 绝不能写进 JS bundle——RN 打包后 bundle 是明文,任意 App 反编译都能拿到密钥。预计耗时:45 分钟 | 难度:进阶
适用场景
- 做 RN 跨平台 App(iOS + Android 一套代码),要接 AI 做对话 / 摘要 / 翻译 / 文案生成
- 已有 iOS / Android 原生 App,想统一用 RN 重做 AI 层,共用 Web 端那套 CloudBase AI 后端代理
- 已经用 add-ai-nextjs 跑通了 Web 端,现在要把同一套 Route Handler 给 RN App 复用——后端完全不动,只补 RN 前端流式读取
不适用:
- Expo Go 环境(Expo 官方预编译的 Go App,锁了原生模块,无法安装
react-native-fetch-api这类需要 link 的 polyfill)——必须改用 Expo dev build 或裸 RN 工程 - 微信小程序场景——小程序自带
wx.cloud身份,直接走 add-ai-wechat-miniprogram,不需要后端代理 - 纯 Web / H5 场景——走 add-ai-nextjs,
@cloudbase/js-sdk + signInAnonymously那套只在浏览器 demo 里有意义,RN 也不能用 - 想直接在 RN 里
import tcb from '@cloudbase/node-sdk'——Node SDK 依赖fs / http / crypto等 Node 内置模块,RN(Hermes / JSC)都跑不起来
为什么 RN 必须走后端代理
跟 Web 一样,RN 客户端没有受信凭证持有方:
- RN 打包出来的 JS bundle 是明文(release 模式 Hermes bytecode 也能 反编译回 JS),把 SecretId / SecretKey 写在代码里 = 直接公开发密钥
@cloudbase/js-sdk的signInAnonymously()在 RN 里能跑,但它的匿名身份默认会被严格限频(详见 Web SDK 安全策略),只适合 demo,生产请求会被风控拦- 小程序之所以能直连
wx.cloud.extend.AI,是因为wx.cloud调用本身就带了用户的微信登录态——RN 没有这种平台身份注入机制
所以 RN 接 CloudBase AI 的标准姿势是:
RN App (fetch) ──HTTPS──▶ 后端 Route Handler (持 SecretKey) ──▶ CloudBase AI streamText ──▶ 流式响应回 RN
后端 Route Handler 跟 add-ai-nextjs 第三步一模一样,本篇不再重复造轮子,只补 RN 前端怎么读流。
环境要求
| 依赖 | 版本 |
|---|---|
| React Native | 0.74+(New Architecture / Fabric 稳定,默认 Hermes,fetch 大部分场景已支持 body.getReader()) |
| Node.js(开发机) | ≥ 20 |
| Xcode(iOS) | ≥ 15.0,iOS 部署目标 ≥ 13.4 |
| Android Studio | ≥ 2024.1,Android minSdkVersion ≥ 24 |
@cloudbase/node-sdk(后端用) | ≥ 3.16.0(AI 模块要求) |
react-native-fetch-api(可选 polyfill) | ≥ 3.0.0(RN 老版本或 fetch 行为异常时装) |
| CloudBase 环境 | 已开通,且控制台已开通「AI+」能力 |
工程模板推荐:
- 新项目:
npx @react-native-community/cli@latest init MyApp走裸 RN 0.76+,默认就是 New Architecture - 跨平台 + 想用 EAS Build:走 Expo dev build(
npx create-expo-app + npx expo run:ios),不要用 Expo Go
想偷懒在客户端直接
import tcb from '@cloudbase/node-sdk'不行——Node SDK 依赖fs / http / crypto,RN bundler(Metro)打不进去,即使打进去 Hermes 也跑不起来。服务端必须是真正的 Node 环境(Next.js Route Handler、云函数、云托管)。
第一步:控制台开通 AI 能力 + 选模型
跟 add-ai-nextjs 第一步完全一样:
- 进 CloudBase 控制台 → 选你的环境 → AI+ → 快速接入
- 第一次进点「立即开通」,自动给环境注入 AI 调用权限。开通免费,调用按 token 计费
- 「模型管理」里能看到当前环境可用的模型列表。CloudBase 通过 Token 资源包统一接入 DeepSeek、MiniMax、混元、Kimi、GLM 等主流模型,官方主推
deepseek-v4-flash(性价比 + 通用对话默认),完整列表见 接入大模型
下文示例统一用 deepseek-v4-flash。
第二步:后端 Route Handler(跟 Next.js 那篇一样,直接复用)
后端代理可以部署到三个地方,任选一个,接口形态完全一样:
| 部署形态 | 入口 | 凭证注入方式 |
|---|---|---|
| Next.js 部署到 Vercel / 自建机器 | app/api/chat/route.ts(App Router) | .env 配 TENCENTCLOUD_SECRETID/KEY |
| 部署到 CloudBase 云托管 | 任意 Node Web 框架(Express / Hono / Next.js)/api/chat 路由 | 容器自动注入,无需手填密钥 |
| 部署到 CloudBase 云函数 | HTTP 触发器,函数代码内 tcb.init() | 同上,自动注入 |
代码以 Next.js Route Handler 为例(app/api/chat/route.ts):
import tcb from '@cloudbase/node-sdk';
export const runtime = 'nodejs'; // 关键:不能用 edge,SDK 依赖 Node API
let app: ReturnType<typeof tcb.init> | null = null;
function getAi() {
if (!app) {
// node-sdk 自动从 TENCENTCLOUD_SECRETID/SECRETKEY 读凭证
// timeout 60s:AI 长输出场景默认 15s 会被击穿
app = tcb.init({ env: process.env.CLOUDBASE_ENV!, timeout: 60000 });
}
return app.ai();
}
export async function POST(req: Request) {
const { messages } = await req.json();
const ai = getAi();
const model = ai.createModel('cloudbase');
const result = await model.streamText({
model: 'deepseek-v4-flash',
messages,
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of result.textStream) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
// RN fetch 在 Android 上偶尔会因为 keep-alive 复用连接读不到完整流,显式 close 一下
'Connection': 'close',
},
});
}
要点:
- 服务端必须用
@cloudbase/node-sdk,不要用@cloudbase/js-sdk + signInAnonymously()那套 Web 端写法(原因见前面「为什么 RN 必须走后端代理」) runtime = 'nodejs',不能edgetcb.init({ timeout: 60000 })显式拉到 60s,默认 15s 会被流式长输出击穿- 返回
text/plain流式 Response;不要返回 SSEtext/event-stream,RN 端没有原生 EventSource,要么走本篇的 fetch + reader,要么装react-native-sse单独处理协议 - 部署到 CloudBase 云托管 / 云函数时,密钥自动注入,不要在代码里硬编码
如果后端跑在 CloudBase 云托管,前端调用 URL 形如 https://<service>-<envId>.ap-shanghai.app.tcloudbase.com/api/chat;部署到 Vercel 就是 https://<your-app>.vercel.app/api/chat;部署在自建机器要自己解 HTTPS + 域名。
域名必须是 HTTPS。iOS 默认 ATS(App Transport Security)直接拒 HTTP;Android 9+(API 28+)默认 cleartext traffic 也禁。本地调试用 HTTP 见第四步配置例外。
第三步:RN 前端 — 装依赖
进 RN 项目根目录:
# 0.74+ Hermes 默认 fetch 大部分场景能用 body.getReader(),保险起见仍装 polyfill
npm install react-native-fetch-api react-native-polyfill-globals
# iOS 链接原生依赖
cd ios && pod install && cd ..
react-native-fetch-api 给 RN 装了一份能正确 await body.getReader().read() 的 fetch 实现(老版本 RN 的 fetch 是 XHR 包出来的,根本拿不到 ReadableStream;0.74+ 大部分 Engine 已支持,装 polyfill 是双保险)。
在 App 入口最早的位置打开 polyfill(index.js 第一行):
// index.js
import { polyfillGlobal } from 'react-native/Libraries/Utilities/PolyfillFunctions';
import { fetch, Headers, Request, Response } from 'react-native-fetch-api';
polyfillGlobal('fetch', () =>
(...args) => fetch(args[0], { ...args[1], reactNative: { textStreaming: true } })
);
polyfillGlobal('Headers', () => Headers);
polyfillGlobal('Request', () => Request);
polyfillGlobal('Response', () => Response);
// 你原本的 RN 入口逻辑
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
AppRegistry.registerComponent(appName, () => App);
reactNative: { textStreaming: true } 这个非标准选项是 react-native-fetch-api 用来打开「真流式」模式的开关,不加它,fetch 行为退化成「等响应体完整收完再 resolve」,UI 上看就是「憋一阵后整段冒出 来」。
第四步:RN 前端 — iOS / Android 网络配置
iOS(ios/MyApp/Info.plist)
生产环境用 HTTPS 不需要改任何 ATS 配置。本地调试如果非要用 HTTP(例如连内网 http://192.168.x.x:3000),临时打个例外:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>192.168.1.100</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
不要在生产开 NSAllowsArbitraryLoads,App Store 审核会卡。
Android
android/app/src/main/AndroidManifest.xml 在 <application> 之外加权限,在 <application> 之内 加 networkSecurityConfig 引用:
<uses-permission android:name="android.permission.INTERNET" />
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
Android 9+(API 28+)默认禁明文 HTTP。生产 HTTPS 不需要这个文件;本地调试 HTTP 时新建 android/app/src/main/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.1.100</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>
10.0.2.2 是 Android 模拟器访问宿主机的特殊地址。生产不要全局开 cleartextTrafficPermitted="true"。
第五步:RN 前端 — fetch + ReadableStream 消费流
App.tsx:
import React, { useRef, useState } from 'react';
import {
FlatList,
KeyboardAvoidingView,
Platform,
SafeAreaView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
// 后端 Route Handler 的 URL(部署到 CloudBase 云托管 / Vercel / 自建机器都行)
const API_URL = 'https://your-backend.example.com/api/chat';
type Message = { role: 'user' | 'assistant'; content: string };
export default function App() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const listRef = useRef<FlatList<Message>>(null);
async function send() {
const text = input.trim();
if (!text || loading) return;
const userMsg: Message = { role: 'user', content: text };
const aiMsg: Message = { role: 'assistant', content: '' };
const next = [...messages, userMsg, aiMsg];
setMessages(next);
setInput('');
setLoading(true);
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [...messages, userMsg] }),
// @ts-ignore react-native-fetch-api 的扩展选项
reactNative: { textStreaming: true },
});
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let acc = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream:true 必须加,否则跨 chunk 边界的中文会被劈成乱码 \uFFFD
acc += decoder.decode(value, { stream: true });
setMessages((prev) => {
const copy = [...prev];
copy[copy.length - 1] = { role: 'assistant', content: acc };
return copy;
});
// FlatList 跟随滚动到底
listRef.current?.scrollToEnd({ animated: false });
}
} catch (err) {
console.error('[chat] fetch failed', err);
setMessages((prev) => {
const copy = [...prev];
copy[copy.length - 1] = {
role: 'assistant',
content: `[出错] ${err instanceof Error ? err.message : String(err)}`,
};
return copy;
});
} finally {
setLoading(false);
}
}
return (
<SafeAreaView style={styles.safe}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<FlatList
ref={listRef}
data={messages}
keyExtractor={(_, i) => String(i)}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View
style={[
styles.bubble,
item.role === 'user' ? styles.userBubble : styles.aiBubble,
]}
>
<Text style={styles.role}>{item.role}</Text>
<Text style={styles.content}>{item.content}</Text>
</View>
)}
/>
<View style={styles.inputBar}>
<TextInput
style={styles.input}
value={input}
onChangeText={setInput}
placeholder="问点什么"
editable={!loading}
onSubmitEditing={send}
returnKeyType="send"
/>
<TouchableOpacity
style={[styles.button, (loading || !input.trim()) && styles.buttonDisabled]}
disabled={loading || !input.trim()}
onPress={send}
>
<Text style={styles.buttonText}>{loading ? '生成中' : '发送'}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#fff' },
container: { flex: 1 },
list: { padding: 12, gap: 8 },
bubble: { padding: 10, borderRadius: 8 },
userBubble: { backgroundColor: '#e6f0ff', alignSelf: 'flex-end', maxWidth: '80%' },
aiBubble: { backgroundColor: '#f5f5f5', alignSelf: 'flex-start', maxWidth: '80%' },
role: { fontSize: 11, color: '#888', marginBottom: 4 },
content: { fontSize: 15, color: '#111', lineHeight: 22 },
inputBar: {
flexDirection: 'row',
padding: 8,
borderTopWidth: 1,
borderTopColor: '#eee',
gap: 8,
},
input: {
flex: 1,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
backgroundColor: '#f5f5f5',
fontSize: 15,
},
button: {
paddingHorizontal: 16,
justifyContent: 'center',
backgroundColor: '#1677ff',
borderRadius: 6,
},
buttonDisabled: { backgroundColor: '#aaa' },
buttonText: { color: '#fff', fontSize: 15 },
});
要点:
decoder.decode(value, { stream: true })的stream: true不能漏——中文一个字符 UTF-8 通常占 3 字节,流式 chunk 边界可能恰好把一个汉字劈成两半,不带stream: true那半个汉字会被解成\uFFFD替换字符- 给 assistant 消息预先 push 一条空 content,后续每次 setState 替换最后一条,UI 是「在原位增长」而不是「先空一会再整段冒出来」
FlatList的scrollToEnd({ animated: false })每 chunk 都调一次没事——FlatList 是虚拟化列表,只有可见区域 cell 重排,没有性能问题。真正要节流的不是 scroll 而是 setState:如果模型吐字超快(deepseek-v4-flash 这种 flash 系列一次几百字),可以加 80ms 节流(参考 add-ai-wechat-miniprogram 第三步的lastFlushAt)