一、两个项目,同一天,两种交付形态
上周两个项目同一天找上门。
A 客户:全屋门铃 + 门口机,合同写「白牌 App,对讲延迟不能让人感觉对话卡顿」。B 客户:连锁便利店 12 店,老板只说:「下周投资人来看店,浏览器能出画就行,别谈应用商店。」
设备都是同一品牌云 IPC,开放平台账号也是同一类。差别只在一句选型:走原生 OpenSDK,还是走 Web 轻应用? 选错,不是多写两周代码------是交付形态整条换轨。
下面按「怎么判、怎么建、怎么验收」写一版可抄作业的选型笔记(技术内核可运行,叙述故意写软一点)。
二、认知:为什么「选型」比「会不会调接口」更值钱
2.1 全屋视频项目真正在验收什么
| 业务诉求 | 用户真实感知 | 技术落点 |
|---|---|---|
| 门铃对讲 | 说话 ↔ 听声的延迟、是否可双向 | 原生 SDK 对讲 / 轻应用浏览器对讲 |
| 配网入户 | 工人能否把设备绑进你的体系 | OpenSDK 设备添加模块(轻应用不负责配网) |
| 老板演示 / 物业大屏 | 浏览器打开就能看 | Web 轻应用播放器 / OpenAPI 签发 HLS |
| 设备台账 / 告警进业务系统 | 列表、在线、消息回调 | OpenAPI |
很多人默认「做一个 App」------但 B 类项目根本不需要上架;也有人默认「先用网页糊一个」------但门铃对讲一旦进入验收,Web 路径的延迟与权限边界会立刻露馅。
2.2 三条路径,先画边界
text
┌──────────────────────────────────────────────────────────┐
│ 你的业务系统(账号、工单、门店、全屋场景) │
│ ↑ BFF(仅服务端持有 appSecret) │
└────────────┬───────────────────────────┬─────────────────┘
│ │
┌───────▼────────┐ ┌────────▼─────────┐
│ 原生 OpenSDK │ │ Web 轻应用 H5 │
│ 配网·低延迟预览 │ │ JS 播放器组件 │
│ 对讲·抓图录像 │ │ kitToken 出画 │
└───────┬────────┘ └────────┬─────────┘
│ │
└──────────┬────────────────┘
▼
厂商视频云 OpenAPI
accessToken · listDeviceDetailsByPage
getKitToken · bindDeviceLive · 消息回调...
| 路径 | 延迟体感 | 配网 | 对讲 | 上架成本 | 典型工期 |
|---|---|---|---|---|---|
| 原生 OpenSDK | 低(原生媒体链路) | ✅ 设备添加模块 | ✅ SDK Talk | 需 Android/iOS 工程 | 1~3 周(有 Demo) |
| Web 轻应用 JS | 中(秒出流,适合浏览器) | ❌ | ✅ 浏览器对讲(有能力设备) | 无上架 | 约 7 天 PoC |
OpenAPI bindDeviceLive HLS |
偏高(3~8s 量级常见) | ❌ | ❌ | 极低 | 1~2 天出流 |
一句话记忆:
- 要白牌、要配网、要对讲延迟敏感 → 原生 OpenSDK 主路径
- 要演示、要门店大屏、要 7 天出画 → Web 轻应用主路径
- OpenAPI 永远做 BFF :签发 token、查设备、告警、可选 HLS 兜底------密钥禁止下发到前端或 App 包内
2.3 选型决策树(可贴进方案书)
text
Q1 是否必须自有品牌原生 App?
├─ 是 → OpenSDK(再问是否要对讲/配网:几乎总是要)
└─ 否 → Q2
Q2 交付形态是否以浏览器 / 企微 / 小程序 WebView 为主?
├─ 是 → Web 轻应用播放器(7 天 PoC)
└─ 否 → Q3
Q3 是否只要「能看」,可接受 HLS 延迟?
├─ 是 → bindDeviceLive + 自建播放器
└─ 否 → 回到 OpenSDK 或轻应用,不要硬扛纯 HLS
认知层到这里就够了:不是谁更高级,是谁匹配交付形态。 下面进入可运行的交付层。
三、共用底座 → 分叉两条路径
两条路径共用同一套底座:注册开发者应用 → 设备进开发者资产池 → BFF 拿 accessToken。先把底座跑通,再分叉。
3.1 Phase 0 · 共用底座(两天内必须验收)
text
① 开发者门户注册 → 创建应用 → 记下 appId / appSecret
② 按控制台选择区域 API 网关(下文用占位符 YOUR_REGION_GATEWAY)
③ 设备绑定到「开发者主账号」(手机原厂 App 能看 ≠ 开发者池有设备)
④ 用 listDeviceDetailsByPage 验收:deviceId 出现且 online
javascript
// server/lib/platform-call.js ------ 两条路径共用
import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';
function calcSign(time, nonce, secret) {
return crypto
.createHash('md5')
.update(`time:${time},nonce:${nonce},appSecret:${secret}`, 'utf8')
.digest('hex');
}
export async function platformCall(method, params = {}) {
const time = Math.floor(Date.now() / 1000);
const nonce = randomUUID();
const body = {
system: {
ver: '1.0',
appId: process.env.APP_ID,
sign: calcSign(time, nonce, process.env.APP_SECRET),
time,
nonce,
},
id: randomUUID(),
params,
};
const res = await fetch(`${process.env.OPENAPI_BASE}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.result?.code !== '0') {
throw new Error(`${method} failed: ${json.result?.code} ${json.result?.msg}`);
}
return json.result.data;
}
let cached = { token: null, exp: 0 };
export async function adminToken() {
if (Date.now() < cached.exp) return cached.token;
const { accessToken, expireTime } = await platformCall('accessToken', {});
// expireTime 为剩余秒数;提前 300s 刷新
cached = { token: accessToken, exp: Date.now() + (expireTime - 300) * 1000 };
return accessToken;
}
bash
# .env
APP_ID=your_app_id
APP_SECRET=your_secret_never_commit
OPENAPI_BASE=https://YOUR_REGION_GATEWAY/openapi
DEVICE_SN=YOUR_DEVICE_SN
设备池验收(选型前必跑):
javascript
// scripts/verify-pool.js
import { platformCall, adminToken } from '../server/lib/platform-call.js';
const token = await adminToken();
const page = await platformCall('listDeviceDetailsByPage', {
token,
page: 1,
pageSize: 50,
source: 'bindAndShare',
});
const list = page.deviceList ?? [];
const hit = list.find((d) => d.deviceId === process.env.DEVICE_SN);
console.table({
total: list.length,
found: Boolean(hit),
online: hit?.deviceStatus === 'online',
audioTalk: /AudioTalk|AudioTalkV1/.test(hit?.deviceAbility ?? ''),
});
if (!hit) {
console.error('设备不在开发者池:检查绑定账号,而不是怀疑选型');
process.exit(1);
}
踩坑 A :手机原厂 App 能预览,脚本 total=0。原因几乎总是------设备绑在私人号,没进开放平台开发者资产池。选型讨论之前,先把这件事钉死。
踩坑 A′ :sign 反复失败报 SN1005 ------ nonce 被复用了,每次请求必须新 UUID。
3.2 Path L · Web 轻应用约 7 天上线(门店 / 演示 / 物业大屏)
Day 1--2:BFF 签发 kitToken
轻应用播放 token 由 getKitToken 获取,有效期大约 2 小时 。实践里建议服务端缓存约 1 小时,不要每次点播放都打开放平台。
javascript
// server/routes/kit-token.js
import { platformCall, adminToken } from '../lib/platform-call.js';
const kitCache = new Map(); // key = `${deviceId}:${channelId}:${type}`
export async function getKitTokenCached(deviceId, channelId = '0', type = '0') {
const key = `${deviceId}:${channelId}:${type}`;
const hit = kitCache.get(key);
if (hit && Date.now() < hit.exp) return hit.token;
const data = await platformCall('getKitToken', {
token: await adminToken(),
deviceId,
channelId: String(channelId),
// 0 全部权限;1 实时预览;2 录像回放;6 云台
type: String(type),
});
kitCache.set(key, {
token: data.kitToken,
exp: Date.now() + 60 * 60 * 1000, // 缓存 1h
});
return data.kitToken;
}
// Express:前端只拿短时 kitToken,永不接触 appSecret
app.get('/api/v1/kit-token', userAuth, async (req, res) => {
const { deviceId, channelId = '0' } = req.query;
// TODO: 校验该登录用户是否有权看这台设备
const kitToken = await getKitTokenCached(deviceId, channelId, '0');
res.json({ kitToken, deviceId, channelId });
});
Day 3--5:前端挂播放器
从开发者门户资源中心下载 Web 轻应用套件,引入播放器 JS/CSS,并把 Wasm 解码资源目录放到 public(较新版本务必检查这一步,路径错会直接 SyntaxError)。
html
<!-- public/store-live.html -->
<link href="/player/player.css" rel="stylesheet" />
<script src="/player/player.js"></script>
<div id="player-root"></div>
<script>
async function mountPlayer() {
const { kitToken, deviceId, channelId } = await fetch(
'/api/v1/kit-token?deviceId=' + encodeURIComponent(DEVICE_SN)
).then((r) => r.json());
// 类名以你下载的套件为准,常见为厂商提供的 Player 构造器
const player = new VendorLightPlayer({
id: 'player-root',
width: 960,
height: 540,
deviceId,
channelId: Number(channelId),
token: kitToken, // 来自 getKitToken,不是 accessToken
type: 1, // 1 直播;2 回放
streamId: 1, // 0 高清;1 标清 ------ 多路演示先用标清
WasmLibPath: '/WasmLib/',
// 设备开启视频加密时必填:自定义密钥 / 设备密码 / 默认 SN
code: DEVICE_SN,
templateMode: 'pc',
controls: true,
controlsConfig: ['play', 'volume', 'talk', 'capture', 'ptz', 'resolution', 'fullScreen'],
handleError(err) {
console.error('play error', err?.errCode, err?.errMsg);
},
handleCallBack(ev) {
if (ev?.type === 'playStart') console.log('出画');
},
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) player.pause();
else player.start();
});
}
mountPlayer();
</script>
说明:上面构造器写成
VendorLightPlayer,是为了养号文去品牌化;你本地对接时换成套件文档里的实际类名即可,参数语义不变 :token= kitToken,type/streamId/WasmLibPath/code按文档填。
Day 6--7:多线程解码响应头 + 演示清单
轻应用常用 SharedArrayBuffer 多线程解码(较新 Chrome / Firefox / Edge),需要页面响应头:
nginx
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Embedder-Policy "require-corp";
javascript
// Node / Express
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
轻应用 7 天验收清单
text
□ listDeviceDetailsByPage 有设备且 online
□ BFF 返回 kitToken,前端永不接触 appSecret
□ Wasm 资源路径正确(否则常见 Unexpected token '<')
□ COOP/COEP 头已配,多线程生效
□ 加密设备 code 字段填对
□ 对讲:浏览器麦克风权限 + 设备能力含 AudioTalk
□ 多屏同时播:控制路数,弱机易卡(canvas 解密渲染更吃 CPU)
踩坑 B :把 accessToken 直接塞进播放器 token ------ 播不起来。轻应用要的是 getKitToken 返回的 kitToken 。
踩坑 C :流地址提前缓存、页面一开就批量预拉 ------ WebSocket 404 / 流源超时。原则:用时再取流。
3.3 Path S · 原生 OpenSDK 低延迟主路径(白牌门铃 / 全屋 App)
Step 1:标准化 Demo 先出画,再换皮
text
DeviceAddModule/ # 配网 + 绑定
MediaModule/ # 预览 / 回放
Talk # 对讲(预览成功后)
MessageModule/ # 可选告警
集成顺序(反踩坑):整包 Demo 出画 → 只移植设备添加 + 媒体预览 → 最后换 UI。不要一上来拆模块「重写播放器」。
Step 2:初始化(host 不要带协议头)
java
// Android --- OpenSDK 初始化
// accessToken 必须由你的 BFF 下发,禁止把 appSecret 打进 APK
String host = "YOUR_REGION_GATEWAY:443"; // 控制台给出的 host,勿写 https://
String token = bffClient.fetchAccessToken();
InitParams initParams = new InitParams(this, host, token);
int ret = LCOpenSDK_Api.initOpenApi(initParams);
if (ret != 0) {
// 查 token 是否过期、host 是否写错
}
配网(模块方式,适合全屋交付):
java
CommonParam commonParam = new CommonParam();
commonParam.setContext(getApplication());
commonParam.setAppId(BuildConfig.OPEN_APP_ID); // 可公开的 appId
// 生产环境:Secret 留在 BFF;Demo 初期可参考官方示例,上线前必须改掉
LCDeviceEngine.newInstance().init(commonParam);
LCDeviceEngine.newInstance().addDevice(this);
工装批量绑定也可用 OpenAPI:
javascript
await platformCall('bindDevice', {
token: await adminToken(),
deviceId: process.env.DEVICE_SN,
code: process.env.DEVICE_VERIFY_CODE,
});
Step 3:实时预览 + 对讲
java
LCOpenSDK_PlayRealWindow playWindow = new LCOpenSDK_PlayRealWindow();
playWindow.initPlayWindow(this, findViewById(R.id.live_window_content), 0, false);
playWindow.setWindowListener(yourListener);
playWindow.openTouchListener();
LCOpenSDK_ParamReal paramReal = /* 用 deviceId/channelId/playToken 等组装 */;
playWindow.playRtspReal(paramReal);
// 对讲:须在预览成功后;能力集含 AudioTalk / AudioTalkV1
playWindow.stopAudio();
LCOpenSDK_Talk.setListener(new LCOpenSDK_TalkerListener());
LCOpenSDK_ParamTalk paramTalk = /* ... */;
LCOpenSDK_Talk.playTalk(paramTalk);
// 结束:
LCOpenSDK_Talk.stopTalk();
LCOpenSDK_Talk.setListener(null);
playWindow.playAudio();
OpenSDK 两周验收(可写进合同附件)
text
□ initOpenApi 成功(host 与区域一致,无 https:// 前缀)
□ 设备添加绑到开发者主账号,listDeviceDetailsByPage 可见
□ 媒体预览出画,弱网可切标清
□ 对讲双向可通(先查 deviceAbility)
□ appSecret 不在客户端;token 可刷新
□ 可选:消息回调把动检推进业务系统
踩坑 D :现场工人用私人账号配网 → 你的 App 列表空。SOP:设备添加必须绑项目 appId 对应主账号 。
踩坑 E :initOpenApi 前 token 为空,或 host 写成带 https:// 的完整 URL ------ 预览直接失败。
3.4 同项目「双轨并存」(很多全屋方案的最终形态)
text
业主 App(OpenSDK)──── 配网 / 低延迟对讲 / 日常看护
物业后台(轻应用)────── 浏览器多店巡检
告警中台(OpenAPI)───── setMessageCallback → 工单
BFF 统一提供:
| 接口 | 用途 | 调用方 |
|---|---|---|
GET /api/v1/devices |
包装 listDeviceDetailsByPage |
App + H5 |
GET /api/v1/access-token |
短时 token(App OpenSDK 初始化) | App |
GET /api/v1/kit-token |
getKitToken |
H5 |
POST /api/v1/alarm/hook |
接收平台回调 | 厂商云 → 你 |
可选 HLS 兜底(分享页极简场景):
javascript
const live = await platformCall('bindDeviceLive', {
token: await adminToken(),
deviceId: process.env.DEVICE_SN,
channelId: '0',
streamId: 1, // 标清
});
// live 内含短时播放地址;勿入库长期缓存
消息回调最小形状(生产环境先回 200):
javascript
app.post('/api/v1/alarm/hook', express.json(), (req, res) => {
res.status(200).end(); // 先应答,再异步
queue.add('alarm', req.body).catch(console.error);
});
四、边界、性能、生产注意
4.1 延迟与体验:别用「感觉」验收
| 场景 | 建议路径 | 验收口径 |
|---|---|---|
| 门铃对讲 | OpenSDK | 对话可连续,无明显「一问一顿」 |
| 门店远程看店 | 轻应用 streamId=1 |
打开后数秒内出画 |
| 多路墙面拼接 | 轻应用慎用多实例 | 限制同时路数;弱机降清晰度 |
| 外部分享链接 | HLS / 轻应用只读权限 kitToken | token 短时、可吊销 |
4.2 安全边界(上线必查)
text
□ appSecret 仅 BFF;APK / 前端包内零 Secret
□ kitToken / live URL 短时有效,禁止写死进前端仓库
□ 用户态鉴权:kit-token 接口必须校验「是否有权看该 deviceId」
□ 对讲、麦克风权限在系统设置与 Manifest / 浏览器两端都要开
□ 配额:每个应用有月度免费调用额度,列表轮询别打太猛
4.3 性能与生产注意
- 轻应用 :多播放器同页吃 CPU;canvas 解密渲染比纯
<video>更重。门店大屏建议「点播一路、切换门店」而不是九宫格硬刚。 - OpenSDK:预览成功后再对讲;结束对讲要清 listener,避免泄漏。
- 回调 :
setMessageCallback必须 先回 HTTP 200 再异步处理,否则云端可能停推。 - 加密设备 :轻应用
code、OpenSDK 解密参数不一致会导致「黑屏但设备 online」------先对照设备密码 / 自定义密钥 / SN 默认规则。 - 区域:App 区域、API 网关、客户端配置三者不一致时,常见症状也是「凭证对、列表空」。
4.4 合同话术(减少扯皮)
业主端:Android/iOS 采用厂商 OpenSDK,负责配网、实时预览与语音对讲。
管理端:Web 采用厂商轻应用播放组件,负责浏览器巡检与演示。
云端:OpenAPI 负责设备台账、直播地址签发与消息回调。
不在本期:纯本地 ONVIF 替换云端能力;不承诺与第三方 RTSP 播放器的对讲互通。
五、选型表 + 可自检清单
| 你的项目更像... | 主选 | 辅选 |
|---|---|---|
| 白牌全屋 / 门铃对讲 / 工人现场配网 | 原生 OpenSDK | OpenAPI 台账 + 可选轻应用管理端 |
| 门店巡检 / 物业大屏 / 7 天演示 | Web 轻应用 | OpenAPI 绑定与告警 |
| 只要临时分享画面 | bindDeviceLive HLS |
后续再升级轻应用 |
选型不是站队,是 把延迟、配网、上架成本三件事和合同对齐。底座永远是:开发者池有设备 + BFF 管密钥。