普通视图
取消电动车项目后,索尼本田移动将缩减运营规模、另寻业务方向
航旅纵横致歉:部分功能出现使用异常,正全力抢修
使用Edge和ADB进行Android Webview远程调试的完整教程
前言
朋友小X在一家小公司从事安卓开发工作。
有一天老板想做一个功能。用户能通过前端网页,调用起原生安卓应用支持的功能,如人脸识别等。
前端开发主要使用Javascript进行开发,安卓应用使用Kotlin进行开发。
Javascript是动态语言,Kotlin是静态语言。
动态语言的优缺点
- 不用考虑声明变量类型,代码编写可以比较随意。
- 没有语法检查工具时,只有运行后才知道,代码有无语法问题。
静态语言的优缺点
- 要考虑声明变量类型。
- 在使用IDE时,可提前发现语法错误。
而小X的公司,没有为前端开发人员提供语法检查工具,只能靠前端开发自查自改。
负责前端网页的妹子是一位新手,写代码经常出现各种语法问题,且不知道如何在Android Webview上进行有效调试。
为了调试,前端妹子只会在有可能出问题的代码,添加alert函数,通过提示框的信息,来进行调试。
使用这种方式进行开发,开发效率非常低下,进度远远落后于计划。
但是老板催着功能赶紧上线,为了尽快上线,小X只能陪着加班,帮助前端妹子排查问题。
陪着妹子加班两天后,小X忍受不了天天要陪加班的状态。
他通过搜索,找到了工具和方法,可让前端在Android Webview高效调试代码。并把方法教给了前端妹子。
前端妹子知道如何用调试工具后,自己可以进行调试,大大提高开发效率,不用小X天天陪着加班了。
工具介绍
刚刚提到的工具,就是Edge + ADB。
ADB是什么?
ADB 的全称是Android Debug Bridge, 是一种功能多样的命令行工具。
ADB 命令可用于执行各种设备操作(例如安装和调试应用),并提供对 Unix shell(可用来在设备上运行各种命令)的访问权限。
现在主流浏览器,包括微软的Edge,还有Android应用自带的Webview,使用的是谷歌出品的浏览器内核。
几年前,遇到类似的问题,只能通过用Chrome浏览器+ADB进行调试。
现在Edge浏览器成了主流,也有和Chrome一样功能,而且不用像Chrome,需下载额外的浏览器插件。
因此,我们的工具选择Edge + ADB。
对于更复杂的调试场景,开发者可以考虑使用专业工具如WebDebugx,它是一款跨平台移动端网页调试工具,提供类似Chrome DevTools的完整调试体验,支持iOS和Android设备远程调试网页和WebView内容,包括网络监控、性能分析和JavaScript控制台集成等功能。
安装及使用
这里以大多数公司常见的Windows系统为例,介绍如何安装及使用。
安装前准备
- 开发电脑
- 安卓手机(已启用开发人员模式)
- ADB工具安装包
- 质量较好的数据线
使用前准备
- 安装Edge浏览器 比较新的电脑,只要预装了正版Windows,Edge是随机附带的)
- 安装ADB(具体步骤可看参考资料一)
- 2.1 下载ADB安装包
- 2.2 解压缩到目标磁盘路径
- 2.3 设置全局变量
- 2.4 在命令行工具,输入以下命令
ADB version
- 2.4.1 能正常显示ADB版本号,则工具准备完毕
- 2.4.2 不能正常显示,需要根据命令行工具的提示,和下面的【注意要点】,进行排查
使用
- 将手机和电脑,通过数据线连接起来。
- 电脑识别出手机后,将手机USB调试模式,设为打开状态
- 打开Edge浏览器
- 浏览器地址栏输入
edge://inspect
- 手机上打开要调试的应用,并进入到要调试的网页
- 点击要调试链接对应的“inspect”按钮
其他步骤与在电脑上调试网页步骤类似
注意要点
有些人在做前期准备时,会遇到各种问题,解决方法汇总如下
-
硬件设置
- Android设备应直接连到开发用电脑
- Android设备和开发电脑都处于亮屏状态
- 确保USB电缆能正常使用,在开发电脑看到Android设备上的文件
-
软件设置
- 开发电脑的系统是Windows,尝试为Android设备安装驱动程序
- 某些Android设备需要特别设置
-
Android设备未显示“允许USB调试”对话框
- 将Android 设备和开发电脑的显示设置,改为永不休眠状态
- Android 的 USB 模式设置为 PTP
- Android 设备上的“开发人员选项”屏幕中选择“撤销 USB 调试授权”,将其重置为全新状态。
截至2025年底,SpaceX总资产为920亿美元
摩根大通力争今年获批在华推出主动型ETF
文件:马斯克和内部人士将在IPO后保留对SpaceX的投票控制权
A股三大指数集体收涨,中国移动涨超3%
国内油价今年首次下调,加满一箱50升92号汽油将少花22元
地产中介“二当家”去年亏损9663万,租赁业务成了压舱石
4月20日晚间,地产经纪公司我爱我家发布2025年年度报告。年报数据显示,我爱我家去年实现营业总收入104.8亿元,同比下滑16.4%;实现住房总交易金额(GTV)约2520亿元,同比下滑12.4%。
在利润方面,报告期内,我爱我家归属母公司所有者净利润为-9663.1万元,同比大幅减少231.63%。继2022年、2023年连续亏损,2024年扭亏为盈之后,我爱我家在去年再度由盈转亏。
我爱我家是地产中介领域规模第二大的企业,仅次于贝壳。从业绩表现来看,我爱我家业绩下滑的核心原因在于房地产经纪业务受市场环境拖累显著。
![]()
我爱我家近三年主要会计数据和财务指标,数据来源:公司年报
我爱我家在年报中明确解释,亏损主要源于三大非经常性因素的叠加影响:一是自持的云南地区部分投资性房地产产生公允价值变动亏损约5349万元;二是新房业务相关应收账款净额1.92亿元,对应计提减值准备约4172万元;三是总部大楼相关固定资产折旧费用约3251万元。
资产价值缩水和营收账款坏账两项合计约9521万元,是导致我爱我家年度亏损的直接原因。从季度表现来看,我爱我家2025年前三季度时间是盈利的,1-3季度归母净利润分别为627.1万元、3213万元、392.7万元,累计盈利约4232万元,但年末的计提资产减值等操作直接吞噬了全年利润。
我爱我家三大核心业务分别为:经纪业务(二手)、资产管理/相寓、新房业务。
其中,房地产经纪业务是我爱我家的核心收入来源,2025年该业务实现GTV约1998亿元,同比-13.7%。实现收入36.3亿元,同比下滑11.4%,毛利率16%,同比下降4.23个百分点,主要受二手房价格下跌、交易活跃度不足影响,佣金收入承压明显。
但从运营效率来看,公司的社区深耕策略成效显著,2025年单店成交额达1.02亿元/间,这一数据优于行业内其他头部企业,意味着单店覆盖的社区“房、客、人、店”资源半径显著扩大,客户黏性与服务效率得到提升。
2025年,相寓实现营收50.1亿元,是我爱我家旗下营收最高的业务板块。截至去年底,相寓在管房源规模达33.7万套,相较去年同期增长11.2%;平均出房天数为8.8天,出租率稳定在95%。
值得注意的是,2025年相寓业务收入同比有所下降,主要由于我爱我家推出的新产品“相寓优选”采用“净额法”核算收入所致——净额法仅将预期有权收取的佣金、手续费部分确认为收入,而非按交易总额确认,若调整回该部分收益,相寓业务毛利率达14.9%,同比提升2.2个百分点。相寓的租赁业务成为我爱我家的业绩稳定器。
靠租赁业务来平滑公司业绩曲线,成为头部地产经纪公司的共同特点。
我爱我家表示,面对过去一年租金回落、需求结构调整的市场环境,相寓仍能实现“质效双升”的良好发展态势,主要得益于主动求变,以产品升级与运营精细化积极应对行业挑战。公司重点推出“相寓优选”新产品,主打“租金无差价、空置期短、专属管家服务”优势,在业主资产收���与租客居住体验之间实现双向赋能。
新房业务是受行业调整影响最为显著的板块,2025年新房找房热度同比下降11%,改善型客户成为主力需求,58.4%的购房客户有改善需求,且以“卖小换大、卖旧换新”为主。受此影响,我爱我家新房业务全年实现收入9亿元,同比下降21.8%,成为三大业务中降幅最大的板块;实现GTV约为343亿元,同比-10.0%;毛利率约为10%,同比下降约1.92个百分点。
我爱我家表示,新房业务收入的下降主要受到行业销售规模和销售价格下降的影响所致。报告期内,公司新房业务应收账款继续保持下降趋势,截至2025年底,公司新房业务相关的应收账款净额已经下降至约1.92亿元,其中一年以内应收账款金额约为1.59亿元,二年到五年应收账款金额仅为0.33亿元,经营风险显著降低。
截至报告期末,国内运营门店总量2475家,其中直营门店2046家,加盟门店429家,经纪人总数约3万人。
2025年,房产中介行业呈现“头部集中、弱者出清”的格局,行业集中度持续提升,头部企业凭借规模优势、多元布局与高效运营,展现出更强的抗周期能力,但短期盈利能力承压。
从行业整体来看,相关统计数据显示,2025年全国经纪人数量减少3.8%,线下门店持续缩减,中小中介企业因资金实力弱、运营效率低逐步退出市场,已经从“胜者为王”的时代到了“剩者为王”阶段,企业现金流、可持续运营能力成为比利润更重要的指标,头部企业有更大的希望穿越周期。
从 0 到 1 做一个支持 NFC 写入的小程序,需要哪些 API?
项目已开源:chungeplus/nfc-scan,配套源码+需求文档,欢迎 star
![]()
先说结论
小程序可以做 NFC,但有明确的能力边界:
- 主要面向 Android 设备
- 基于 NDEF 标准标签做发现、连接和写入
- iOS 端暂时无法按 Android 的方式落地
所以与其纠结"能不能做",不如把**"在能力边界内怎么做"**这件事先摸清楚。
一、我的项目里做了什么
NFC Scan 这个小程序,核心功能就一件:把网页链接、应用包名、音乐直达链接写进 NFC 标签,让用户用手机碰一碰就能跳转到目标内容。
实现的效果就是:
- 用户在小程序里填写目标内容
- 点击开始写入
- 小程序开启 NFC 标签发现
- 用户把手机贴近标签
- 小程序写入 NDEF 记录
- 返回成功或失败
二、正式写代码前,先了解这几个限制
1. 设备限制
- 需要 Android 真机,且设备本身支持 NFC
- 微信基础库版本需要匹配 NFC 能力
- iOS 侧能力不完整,入口说明可以保留,但不要开放写入
2. 标签限制
- 不是所有 NFC 标签都能写,最稳妥的是标准 NDEF 标签
- 标签容量不足、损坏、加密、协议不兼容,都会导致失败
所以一定要做好错误态文案:
当前设备不支持 NFC
不支持的标签技术
写入失败,请重试
标签损坏或容量不足
3. 能力边界
小程序适合做:
- 标签发现
- NDEF 连接
- 标准记录写入
- 写入流程的交互引导
小程序不适合做:
- 全协议全格式兼容
- 底层扇区级操作
- 脱离微信能力边界的 NFC 控制
三、核心 API 就这 7 个
这是实战重点。做 NFC 写入小程序的 API 核心就一组:
1. wx.getNFCAdapter() — 获取适配器
const nfcAdapter = wx.getNFCAdapter();
这是入口,后续所有操作都从这里开始。
2. startDiscovery() — 开始监听标签
nfcAdapter.startDiscovery({
success() {
console.log('开始监听 NFC 标签');
},
fail() {
console.log('监听失败');
}
});
用户点击"开始写入"后调用。
3. onDiscovered() — 监听标签发现
nfcAdapter.onDiscovered((res) => {
// 判断标签类型,决定是否进入写入流程
const techs = res.techs || [];
if (techs.includes('NDEF')) {
this.ndefAdapterWrite();
} else {
this.showError('不支持的标签技术');
}
});
4. getNdef() — 获取 NDEF 实例
const ndef = nfcAdapter.getNdef();
做应用直达、网页链接这类场景,都离不开这个实例。
5. connect() — 连接标签
ndef.connect({
success() {
this.writeRecords();
},
fail(error) {
// 13022 或 "already connected" 说明已连接,可以继续写入
if (error.errCode === 13022) {
this.writeRecords();
} else {
this.showError('连接失败');
}
}
});
6. writeNdefMessage() — 执行写入
ndef.writeNdefMessage({
records: records.map(item => ({
tnf: item.tnf,
id: string2ArrayBuffer(item.id),
type: string2ArrayBuffer(item.type),
payload: buildPayload(item),
})),
success() {
console.log('写入成功');
},
fail() {
console.log('写入失败');
}
});
注意:这里的 records 必须符合 NDEF 规范,不是随便传字符串就行。
7. 资源清理 — 3 个 API
offDiscovered() // 取消监听
stopDiscovery() // 停止发现
close() // 关闭连接
很多人容易忽略这一层,结果弹窗关了还在监听、同一标签被重复触发。
四、NDEF 记录怎么设计
"我要往标签里写什么格式"是第一次做 NFC 开发最容易卡住的地方。
场景一:写入网页链接 → 用 URI 记录
{
tnf: 1,
id: 'web',
type: 'U',
payload: 'https://example.com'
}
场景二:写入应用包名 → 用 Android Application Record
{
tnf: 4,
id: 'pkg',
type: 'android.com:pkg',
payload: 'com.tencent.mobileqq'
}
场景三:写入音乐直达链接
我当时的做法是组合两条记录:
// 网易云音乐
{
tnf: 1,
id: 'music',
type: 'U',
payload: 'orpheus://song/413829859/?autoplay=true'
}
// + 目标 App 包名
{
tnf: 4,
id: 'pkg',
type: 'android.com:pkg',
payload: 'com.netease.cloudmusic'
}
这样体验更稳定,碰一碰就能直接拉起对应 App 播放歌曲。
五、完整写入流程怎么写
我的做法是把 NFC 写入封装成一个独立组件 scan-dialog,原因有两个:
- NFC 写入天然是一个独立状态机
- 等待、写入中、成功、失败、重试,适合做成统一弹窗
整体流程拆解如下:
第一步:准备写入数据
在用户点击"开始写入"后,把业务内容整理成标准 records,传给弹窗组件。
this.setData({
scanVisible: true,
records: [{
tnf: 1,
id: 'web',
type: 'U',
payload: 'https://example.com'
}]
});
第二步:组件显示,开始监听
onShow() {
if (!wx.getNFCAdapter) {
this.setData({ scanStatus: 'error', errorMessage: '当前设备不支持 NFC' });
return;
}
const adapter = wx.getNFCAdapter();
adapter.startDiscovery({
success: () => {
adapter.onDiscovered(this.handleDiscovered);
},
fail: () => {
this.setData({ scanStatus: 'error', errorMessage: '发现NFC设备失败' });
}
});
}
第三步:发现标签后判断类型
handleDiscovered(res) {
const techs = Array.isArray(res.techs) ? res.techs : [];
if (techs.includes('NDEF')) {
this.ndefAdapterWrite();
} else {
this.setData({ scanStatus: 'error', errorMessage: '不支持的标签技术' });
}
}
第四步:连接并写入
ndefAdapterWrite() {
const ndef = this.data.baseNfcAdapter.getNdef();
ndef.connect({
success: () => {
ndef.writeNdefMessage({
records: this.buildRecords(),
success: () => {
this.setData({ scanStatus: 'success' });
},
fail: () => {
this.setData({ scanStatus: 'error', errorMessage: '写入失败,请重试' });
}
});
},
fail: (error) => {
if (error.errCode === 13022) {
// 已连接,直接写入
ndef.writeNdefMessage({ ... });
} else {
this.setData({ scanStatus: 'error', errorMessage: '连接失败' });
}
}
});
}
六、除了 NFC API,还要配哪些能力
很多人以为只要盯着 NFC API 就够了,其实不是。
| API | 用途 |
|---|---|
wx.request() |
音乐分享链接解析 |
wx.getClipboardData() |
一键粘贴 |
wx.canIUse() |
兼容性判断 |
wx.getUpdateManager() |
版本更新 |
七、最容易踩的 5 个坑
1. 不做重复发现保护
标签贴近时 onDiscovered() 可能在短时间触发多次,没加锁就会出现重复写入。
我的做法是加一个 writingLock,发现标签后立刻上锁,写完再解锁。
2. 只管写,不管清理
弹窗关闭后不执行 offDiscovered() + stopDiscovery() + close(),会导致页面状态越来越乱。
3. URI Payload 直接裸写字符串
URI Record 要按 NDEF 规范做前缀编码:
-
https://→ 前缀码0x04 -
http://→ 前缀码0x03 - 自定义协议(
orpheus://、qqmusic://)→ 前缀码0x00
不同场景处理方式不一样,直接写字符串进去大概率会失败。
4. 忽略平台差异
Android 和 iOS 的 NFC 路径完全不同。我的处理方式是 iOS 保留入口但禁用写入,避免用户误解。
5. 只做成功态,不做失败态
真正上线后,失败场景比想象的多:
手机不支持 NFC
标签不是 NDEF 类型
标签已损坏
标签容量不足
连接失败
写入失败
工具类产品尤其需要把失败原因讲清楚,不要只给一个"写入失败"的模糊提示。
八、项目结构一览
miniprogram/
├── components/ # 自定义组件
│ ├── pixel-navbar/ # 像素风导航栏
│ ├── pixel-toast/ # 像素风提示
│ ├── pixel-icon/ # 像素风图标
│ └── scan-dialog/ # NFC 写入弹窗
├── pages/ # 页面
│ ├── write-menu/ # 首页(功能入口)
│ ├── write-app/ # 应用写入
│ ├── write-music/ # 音乐写入
│ └── write-web/ # 网页写入
├── styles/ # 全局样式
├── utils/ # 工具函数
│ ├── convert.js # NDEF 数据转换
│ └── extract.js # 音乐链接解析
└── app.js
九、最后
小程序 NFC 这件事,核心就一句话:在微信提供的能力边界内,把 NDEF 标签的发现、连接、写入和交互流程设计完整。
如果你是自己从零做,建议优先把:
- 标签格式(NDEF 规范)
- 平台边界(Android / iOS 差异)
- 失败态处理(把原因讲清楚)
这三件事先打磨好,再去扩展更多业务能力。
项目已开源到 GitHub:chungeplus/nfc-scan,包含完整源码、需求文档和发布说明,欢迎参考交流。
重构使用的skills:juejin.cn/post/763103…
中创新航在自贡成立科技新公司,含新能源汽车业务
理想汽车宣布首发高德汽车出行AI Agent
我国智能算力规模达1882EFLOPS
纯浏览器解析 APK 信息,不用服务器 | 开源了一个小工具
纯浏览器解析 APK 信息,不用服务器 | 开源了一个小工具
做内部分发平台的时候遇到一个需求:用户上传 APK 后,自动填写包名、版本号、应用名称。
最直接的方案是丢给后端解析,但能不能直接在浏览器里搞定呢?折腾了一番,写了个零依赖(对服务端零依赖)的小包:apk-meta-parser。
能解析什么
import { parseApkMeta } from "apk-meta-parser";
const meta = await parseApkMeta(file); // file 就是 input[type=file] 拿到的 File 对象
// {
// packageName: "com.example.app",
// versionName: "1.2.3",
// versionCode: 123,
// label: "我的应用", // 真实应用名,不是包名
// labelIsResourceId: false,
// apkSize: 10485760,
// apkMd5: "d41d8cd98f00b204e9800998ecf8427e"
// }
三行代码,搞定。
技术上做了什么
APK 本质是个 ZIP,里面的 AndroidManifest.xml 是 Android 二进制 XML 格式(不是普通文本),不能直接读。我手写了一个 AXML 解析器,支持 UTF-8/UTF-16 字符串池,处理了 versionCode 超 32 位的边界情况。
比较麻烦的一个坑是 应用名称。用 uni-app、HBuilderX 打包的 APK,android:label 不是明文字符串,而是一个资源 ID(比如 @0x7f0d001b),真正的名字存在 resources.arsc 里。为了解析这个,又手写了一套 resources.arsc 的 ResTable_type 块解析逻辑,能正确拿到中文应用名。
安装
npm install apk-meta-parser jszip spark-md5
jszip 和 spark-md5 是 peer deps,按需安装(只需要包名版本不计算 MD5 的话可以跳过 spark-md5 用 skipMd5: true)。
使用场景
- APK 分发平台(上传自动识别)
- 移动端 CI/CD 面板
- 任何需要在前端展示 APK 元信息的地方
GitHub:github.com/xuantiandao…
小工具,代码量不大,欢迎看源码提 issue。
Vosk-Browser 实现浏览器离线语音转文字
最近在做一个 AI 客服对话组件,需要接入语音输入。第一反应是用浏览器原生的 window.SpeechRecognition:
const recognition = new webkitSpeechRecognition()
recognition.lang = 'zh-CN'
recognition.onresult = (e) => console.log(e.results[0][0].transcript)
recognition.start()
代码很简单,本地测试也没问题。但上线后发现:在国内完全不可用。
加了完整日志后才搞清楚原因:
recognition.onstart = () => console.log('已启动') // ✅ 触发
recognition.onspeechstart = () => console.log('检测到声音') // ❌ 不触发
recognition.onresult = (e) => console.log('有结果', e) // ❌ 不触发
recognition.onerror = (e) => console.log('错误', e.error) // 有时触发 network
recognition.onend = () => console.log('结束') // ✅ 触发(静默结束)
根本原因:Chrome 的 SpeechRecognition 底层调用 www.google.com/speech-api,这个域名在国内被 GFW 封锁。表现为 onstart 触发后静默等待,最终 onend 直接结束,没有任何识别结果。
解决方案:Vosk-Browser 离线识别
Vosk 是开源的离线语音识别引擎,@lichess-org/vosk-browser 是其 WebAssembly 浏览器版本的维护 fork(原版 vosk-browser 已停止维护)。
核心优势:
- 完全离线,不依赖任何外部服务,国内可用
- 支持中文,小模型约 40MB
- 基于 WebWorker + WASM,识别不阻塞主线程
准备工作
1. 安装依赖
npm install @lichess-org/vosk-browser
2. 复制静态文件到 public 目录
cp node_modules/@lichess-org/vosk-browser/dist/vosk.wasm.js public/
cp node_modules/@lichess-org/vosk-browser/dist/vosk.worker.js public/
cp node_modules/@lichess-org/vosk-browser/dist/vosk.wasm public/
Worker 和 WASM 文件必须放到可以直接通过 URL 访问的静态目录,不能走 webpack 打包。
3. 下载中文模型
https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip
下载后放到 public/ 目录。建议放自己服务器,不要依赖 CDN,国内访问不稳定。
核心 API
import { createVoskClient } from '@lichess-org/vosk-browser'
// 1. 加载模型(只需一次,内部启动 WebWorker)
const client = await createVoskClient({
workerUrl: '/vosk.worker.js',
wasmUrl: '/vosk.wasm',
modelUrl: '/vosk-model-small-cn-0.22.zip'
})
// 2. 创建识别器(每次录音新建一个)
const recognizer = new client.KaldiRecognizer(16000) // 采样率必须 16000
// 3. 监听识别结果(每句话结束触发一次)
recognizer.on('result', (msg) => {
console.log(msg.result.text) // 识别出的文字
})
// 4. 喂音频数据(通过 ScriptProcessor 实时传入)
recognizer.acceptWaveformFloat(float32Array, 16000)
// 5. 停止时获取最后一段未提交的结果
recognizer.retrieveFinalResult()
// 6. 销毁识别器
recognizer.remove()
实现要点
要点一:页面空闲时预加载模型
模型首次加载需要解压 WASM,用 requestIdleCallback 在页面空闲时静默加载,用户点击录音时已就绪:
async function preloadVosk() {
if (vosk.client || vosk.loading) return
vosk.loading = true
try {
vosk.client = await Promise.race([
createVoskClient({
workerUrl: '/vosk.worker.js',
wasmUrl: '/vosk.wasm',
modelUrl: '/vosk-model-small-cn-0.22.zip'
}),
// 加超时保护,防止模型文件不存在时永远挂起
new Promise((_, reject) => setTimeout(() => reject(new Error('模型加载超时')), 60000))
])
} catch (e) {
vosk.client = null
} finally {
vosk.loading = false
}
}
// 页面空闲时预加载
if (window.requestIdleCallback) {
requestIdleCallback(() => preloadVosk(), { timeout: 3000 })
} else {
setTimeout(preloadVosk, 2000)
}
要点二:用 AudioContext + ScriptProcessor 喂数据
Vosk 需要 16000Hz 的 PCM Float32 音频流:
const sampleRate = 16000
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const audioCtx = new AudioContext({ sampleRate })
const source = audioCtx.createMediaStreamSource(stream)
const processor = audioCtx.createScriptProcessor(4096, 1, 1)
source.connect(processor)
processor.connect(audioCtx.destination)
processor.onaudioprocess = (e) => {
// 实时把麦克风数据喂给 Vosk
recognizer.acceptWaveformFloat(e.inputBuffer.getChannelData(0), sampleRate)
}
要点三:避免识别文本重复
录音过程中 result 事件持续触发(每句话结束一次),停止时还需调 retrieveFinalResult() 获取最后一段。
如果在 startVoice 和 stopVoice 里各绑一次 result 监听,最后一段文本会被计算两遍。
用 finalizing 标志区分:
// 开始录音时绑定监听,录音中累积文本
recognizer.on('result', (msg) => {
const text = msg?.result?.text?.replace(/\s+/g, '') || ''
if (!text) return
if (vosk.finalizing) {
// retrieveFinalResult 触发的最后一段
inputBox.value += vosk.partialText + text
vosk.partialText = ''
} else {
// 录音中间的结果,先累积
vosk.partialText += text
}
})
// 停止录音
function stopVoice() {
// ... 停止音频流 ...
vosk.finalizing = true
recognizer.on('result', () => {
recognizer.remove()
vosk.finalizing = false
})
recognizer.retrieveFinalResult()
}
要点四:Vue 2 中不要用 _ 前缀存状态
Vue 2 不会代理 _ 或 $ 开头的 data 属性,直接赋值会是 undefined:
// ❌ 错误:this._voskClient 永远是 undefined
data() {
return { _voskClient: null }
}
// ✅ 正确:在 mounted 里直接挂到实例上(非响应式)
mounted() {
this.$vosk = {
client: null, loading: false,
recognizer: null, stream: null,
audioCtx: null, processor: null,
partialText: '', finalizing: false
}
}
完整 H5 Demo(纯离线版)
以下是可直接运行的完整示例,所有依赖全部本地化,无任何外部请求:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>离线语音转文字 Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #0f1117; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh;
}
.chat-box {
width: 400px; height: 600px;
background: linear-gradient(160deg, #1a1d2e 0%, #0f1117 100%);
border-radius: 16px; border: 1px solid rgba(0,200,255,0.15);
box-shadow: 0 8px 40px rgba(0,0,0,0.6);
display: flex; flex-direction: column; overflow: hidden; position: relative;
}
.chat-header {
padding: 14px 16px; background: rgba(0,200,255,0.06);
border-bottom: 1px solid rgba(0,200,255,0.1);
display: flex; align-items: center; gap: 8px;
font-size: 15px; font-weight: 600; color: #00c8ff;
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #00c8ff; animation: blink 1.4s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
.chat-content {
flex: 1; overflow-y: auto; padding: 16px 12px;
display: flex; flex-direction: column; gap: 12px;
}
.chat-content::-webkit-scrollbar { width: 4px; }
.chat-content::-webkit-scrollbar-thumb { background: rgba(0,200,255,0.2); border-radius: 2px; }
.msg { display: flex; gap: 8px; max-width: 85%; }
.msg.user { align-self: flex-end; flex-direction: row-reverse; }
.msg-avatar {
width: 32px; height: 32px; border-radius: 50%;
background: rgba(0,200,255,0.15); border: 1px solid rgba(0,200,255,0.3);
display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0;
}
.msg.user .msg-avatar { background: rgba(100,180,255,0.15); }
.msg-bubble {
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px; padding: 8px 12px; font-size: 13px; line-height: 1.6;
}
.msg.user .msg-bubble { background: rgba(0,200,255,0.12); border-color: rgba(0,200,255,0.2); }
.msg-time { font-size: 10px; color: #555; margin-top: 4px; }
.quick-cmds { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px 4px; }
.quick-cmd {
padding: 4px 12px; border-radius: 12px;
background: rgba(0,200,255,0.08); border: 1px solid rgba(0,200,255,0.2);
font-size: 12px; color: #00c8ff; cursor: pointer;
}
.quick-cmd:hover { background: rgba(0,200,255,0.18); }
.chat-input {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.06);
}
.input-box {
flex: 1; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px; padding: 8px 14px; color: #e0e0e0; font-size: 13px; outline: none;
}
.input-box:focus { border-color: rgba(0,200,255,0.4); }
.input-box::placeholder { color: #555; }
.btn-send, .btn-mic {
width: 34px; height: 34px; border-radius: 50%; border: none;
cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: 15px; flex-shrink: 0;
}
.btn-send { background: #00c8ff; color: #000; }
.btn-mic { background: rgba(0,200,255,0.1); border: 1px solid rgba(0,200,255,0.3); color: #00c8ff; }
.btn-mic.recording { background: rgba(255,71,87,0.2); border-color: #ff4757; color: #ff4757; animation: pulse-red 1s infinite; }
@keyframes pulse-red { 0%,100%{box-shadow:0 0 0 0 rgba(255,71,87,0.4)} 50%{box-shadow:0 0 0 6px rgba(255,71,87,0)} }
.voice-overlay {
position: absolute; bottom: 60px; left: 50%; transform: translateX(-50%);
background: rgba(10,12,20,0.92); border: 1px solid rgba(0,200,255,0.2);
border-radius: 16px; padding: 16px 24px;
display: none; flex-direction: column; align-items: center; gap: 12px;
z-index: 20; min-width: 200px;
}
.voice-overlay.show { display: flex; }
.voice-wave { display: flex; align-items: flex-end; gap: 4px; height: 32px; }
.voice-wave span { width: 4px; border-radius: 2px; background: #00c8ff; animation: wave-bar 0.8s ease-in-out infinite; }
.voice-wave span:nth-child(1){height:8px;animation-delay:0s}
.voice-wave span:nth-child(2){height:18px;animation-delay:0.1s}
.voice-wave span:nth-child(3){height:28px;animation-delay:0.2s}
.voice-wave span:nth-child(4){height:18px;animation-delay:0.3s}
.voice-wave span:nth-child(5){height:8px;animation-delay:0.4s}
@keyframes wave-bar { 0%,100%{transform:scaleY(1);opacity:0.7} 50%{transform:scaleY(1.6);opacity:1} }
.voice-tip { font-size: 12px; color: #aaa; }
.voice-actions { display: flex; gap: 24px; }
.voice-action {
display: flex; flex-direction: column; align-items: center; gap: 4px;
cursor: pointer; font-size: 12px; color: #aaa; padding: 6px 12px;
border-radius: 8px; transition: all 0.2s;
}
.voice-action .icon { font-size: 20px; }
.voice-action.cancel:hover { color: #ff4757; background: rgba(255,71,87,0.1); }
.voice-action.confirm:hover { color: #00c8ff; background: rgba(0,200,255,0.1); }
.status-bar { padding: 4px 12px 8px; font-size: 11px; color: #555; text-align: center; }
.status-bar.ready { color: #67c23a; }
.status-bar.error { color: #f56c6c; }
</style>
</head>
<body>
<div class="chat-box">
<div class="chat-header">
<div class="dot"></div>
AI 智能助手(离线语音版)
</div>
<div class="chat-content" id="chatContent"></div>
<div class="quick-cmds">
<span class="quick-cmd" onclick="sendText('你好')">你好</span>
<span class="quick-cmd" onclick="sendText('帮助')">帮助</span>
<span class="quick-cmd" onclick="sendText('测试语音')">测试语音</span>
</div>
<!-- 录音遮罩 -->
<div class="voice-overlay" id="voiceOverlay">
<div class="voice-wave">
<span></span><span></span><span></span><span></span><span></span>
</div>
<div class="voice-tip">正在录音,请说话...</div>
<div class="voice-actions">
<div class="voice-action cancel" onclick="cancelVoice()">
<span class="icon">🗑</span><span>取消</span>
</div>
<div class="voice-action confirm" onclick="stopVoice()">
<span class="icon">✅</span><span>转文本</span>
</div>
</div>
</div>
<div class="chat-input">
<button class="btn-mic" id="btnMic" onclick="startVoice()" title="点击录音">🎤</button>
<input class="input-box" id="inputBox" placeholder="输入消息..." onkeydown="if(event.key==='Enter')sendMessage()" />
<button class="btn-send" onclick="sendMessage()">➤</button>
</div>
<div class="status-bar" id="statusBar">语音模型加载中...</div>
</div>
<script type="module">
// 所有文件全部本地化,无外部依赖
import { createVoskClient } from '/vosk.wasm.js'
const vosk = {
client: null, loading: false,
recognizer: null, stream: null,
audioCtx: null, processor: null,
partialText: '', finalizing: false
}
let isRecording = false
const chatContent = document.getElementById('chatContent')
const inputBox = document.getElementById('inputBox')
const btnMic = document.getElementById('btnMic')
const voiceOverlay = document.getElementById('voiceOverlay')
const statusBar = document.getElementById('statusBar')
// ── 预加载模型 ──────────────────────────────────────────────
async function preloadVosk() {
if (vosk.client || vosk.loading) return
vosk.loading = true
setStatus('语音模型加载中...', '')
try {
vosk.client = await Promise.race([
createVoskClient({
workerUrl: '/vosk.worker.js',
wasmUrl: '/vosk.wasm',
modelUrl: '/vosk-model-small-cn-0.22.zip'
}),
new Promise((_, r) => setTimeout(() => r(new Error('加载超时,请确认模型文件已放置')), 60000))
])
setStatus('语音模型已就绪,可点击 🎤 录音', 'ready')
} catch (e) {
vosk.client = null
setStatus('模型加载失败:' + e.message, 'error')
} finally {
vosk.loading = false
}
}
// ── 开始录音 ────────────────────────────────────────────────
window.startVoice = async function () {
if (isRecording) return
if (!vosk.client) {
if (vosk.loading) { alert('模型加载中,请稍候...'); return }
await preloadVosk()
if (!vosk.client) return
}
try {
const sampleRate = 16000
vosk.recognizer = new vosk.client.KaldiRecognizer(sampleRate)
vosk.partialText = ''
vosk.finalizing = false
vosk.recognizer.on('result', (msg) => {
const text = msg?.result?.text?.replace(/\s+/g, '') || ''
if (!text) return
if (vosk.finalizing) {
const full = vosk.partialText + text
if (full) inputBox.value += full
vosk.partialText = ''
} else {
vosk.partialText += text
}
})
vosk.stream = await navigator.mediaDevices.getUserMedia({ audio: true })
vosk.audioCtx = new AudioContext({ sampleRate })
const source = vosk.audioCtx.createMediaStreamSource(vosk.stream)
vosk.processor = vosk.audioCtx.createScriptProcessor(4096, 1, 1)
source.connect(vosk.processor)
vosk.processor.connect(vosk.audioCtx.destination)
vosk.processor.onaudioprocess = (e) => {
if (vosk.recognizer) vosk.recognizer.acceptWaveformFloat(e.inputBuffer.getChannelData(0), sampleRate)
}
isRecording = true
btnMic.classList.add('recording')
voiceOverlay.classList.add('show')
} catch (err) {
alert(err.name === 'NotAllowedError' ? '麦克风权限被拒绝' : '录音启动失败:' + err.message)
}
}
// ── 停止录音(转文本) ───────────────────────────────────────
window.stopVoice = function () {
if (!isRecording) return
isRecording = false
btnMic.classList.remove('recording')
voiceOverlay.classList.remove('show')
if (vosk.processor) { vosk.processor.disconnect(); vosk.processor = null }
if (vosk.audioCtx) { vosk.audioCtx.close(); vosk.audioCtx = null }
if (vosk.stream) { vosk.stream.getTracks().forEach(t => t.stop()); vosk.stream = null }
if (vosk.recognizer) {
vosk.finalizing = true
vosk.recognizer.on('result', () => {
vosk.recognizer.remove()
vosk.recognizer = null
vosk.finalizing = false
})
vosk.recognizer.retrieveFinalResult()
}
}
// ── 取消录音 ────────────────────────────────────────────────
window.cancelVoice = function () {
isRecording = false
btnMic.classList.remove('recording')
voiceOverlay.classList.remove('show')
if (vosk.processor) { vosk.processor.disconnect(); vosk.processor = null }
if (vosk.audioCtx) { vosk.audioCtx.close(); vosk.audioCtx = null }
if (vosk.stream) { vosk.stream.getTracks().forEach(t => t.stop()); vosk.stream = null }
if (vosk.recognizer){ vosk.recognizer.remove(); vosk.recognizer = null }
vosk.partialText = ''
}
// ── 消息收发 ────────────────────────────────────────────────
window.sendMessage = function () {
const text = inputBox.value.trim()
if (!text) return
appendMsg('user', text)
inputBox.value = ''
setTimeout(() => appendMsg('ai', getReply(text)), 600)
}
window.sendText = function (text) {
inputBox.value = text
sendMessage()
}
function appendMsg(type, content) {
const div = document.createElement('div')
div.className = 'msg ' + type
const now = new Date()
const time = `${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`
div.innerHTML = `
<div class="msg-avatar">${type === 'user' ? '👤' : '🤖'}</div>
<div class="msg-bubble">
<div>${content}</div>
<div class="msg-time">${time}</div>
</div>`
chatContent.appendChild(div)
chatContent.scrollTop = chatContent.scrollHeight
}
function getReply(text) {
const map = {
'你好': '您好!我是 AI 助手,有什么可以帮您?',
'帮助': '您可以直接输入问题,或点击 🎤 使用语音输入。',
'测试语音': '语音识别正常!这是一条测试回复。',
}
for (const k in map) { if (text.includes(k)) return map[k] }
return `您说的是:"${text}",我已收到。`
}
function setStatus(msg, type) {
statusBar.textContent = msg
statusBar.className = 'status-bar ' + (type || '')
}
// 页面空闲时预加载模型
if (window.requestIdleCallback) requestIdleCallback(() => preloadVosk(), { timeout: 3000 })
else setTimeout(preloadVosk, 2000)
appendMsg('ai', '您好!我是 AI 智能助手。点击 🎤 可使用<b>离线语音输入</b>,无需联网,完全本地识别。')
</script>
</body>
</html>
运行前确保以下文件都在 public 根目录:
vosk.wasm.jsvosk.worker.jsvosk.wasmvosk-model-small-cn-0.22.zip
![]()