Tag: 开发记录

开发 AI 对话功能的踩坑记录

最近给公司的 APP 开发了一个 AI 对话的功能,是直接做成 H5 页面内嵌到 APP 中的。功能本身不难,就是调用第三方 AI 接口,做点前端效果,只是过程中踩了点小坑,在此记录下。 Websocket的关闭与重连 我调用的 AI 接口是经过服务端转发了一层,最终调用方式是使用的 Websocket。因为需求的原因,会有很多场景需要重新组装连接地址,然后重新连接 Websocket,这里面就涉及连接关闭问题,只有旧的连接关闭了才能重新连接。 最开始我的做法是在每次连接 Websocket 前都先判断一下是否已有连接,有的话就先关闭,伪代码大致如下: connectWebSocket(){ // 如果有连接则关闭 if(this.socket){ this.socket.close(); this.socket = null; } // 创建新连接 let url = 'xxxx' this.socket = new WebSocket(url); } 这样做看似也没有问题,确实功能也能跑起来,但是发现总会报错一次,这个报错一直没找到原因。后来跟服务端沟通了下,才知道光前端这边关闭也不行,也需要等服务端那边关闭才行。也就是说我在前端关闭连接后,马上就重新连接了新的,此时服务端可能还来不及响应或者判断(这里服务端说的也比较含糊),但我理解了我这边关闭后应该等待一下再重新连接,因为 Websocket 关闭也会有一个“握手”的过程,只有服务端也响应了关闭帧才算真正关闭了这个连接。 等待连接,加个定时器是最简单的方式,但我不想这样干,因为这个等待的时间是不确定的,我不想给一个较长的时间,也不敢给一个太短的时间(其实应该会很快)。后来我想到了另一个方式,Websocket 关闭后是有关闭回调的,因为不熟悉它所以一开始没想到这个,有了这个回调那就好办了,我在关闭回调中判断是否需要重连即可。伪代码: // 开启连接 connectWebSocket(){ // ... // 连接关闭回调 this.socket.onclose = () => { this.socket = null // 判断是否需要重新连接,这个状态是在关闭前决定的 if (this.needReconnect) { setTimeout(() => { this.connectWebSocket(); }, 1000); } } } // 关闭连接 closeConnect(needReconnect) { if ( this.socket && this.socket.readyState !== WebSocket.CLOSED && this.socket.readyState !== WebSocket.CLOSING ) { this.needReconnect = needReconnect; this.socket.close(); this.socket = null; } } 这样在需要重新连接的时候,直接调用 closeConnect 方法,并根据当前情况决定是否要重新连接,传入对应的参数即可。 消息渲染 Websocket 返回的 AI 回复内容,是一段一段的,并不是一次性下发完整的内容,所以需要拼接消息,然后也做上了打字机效果。这里面有个问题就是回复的内容是 Markdown 格式的,你需要将文本转为 Html 格式再渲染。在转换格式的时机上,走了两次弯路导致效果不理想。 最开始我做的是每次接收到消息时,就直接转换为 html 文本再拼接,伪代码: // WebSocket 接收消息时调用 receiveMessage(content) { const newHtml = marked(content); // 将 Markdown 转为 HTML this.fullHtmlBuffer += newHtml; // 将 HTML 内容追加到缓冲区 // 拿 this.fullHtmlBuffer 内容做打字机效果,拼接所有消息 this.startTypingEffect(); } 上面这样写的话,问题很严重,因为 markdown 解析基本失败了。稍微思考下也发现了原因,就是 AI 回复的内容不仅是一段一段的,连成对出现的 markdown 符号可能都是拆散的,比如文本加粗,它的回复可能是 **hello 、 ,world* 和 * 这三段,显然单独解析哪一段都不能成功,所以后来调整了方式,就是先拼接 markdown 文本,再解析格式: receiveMessage(content){ // 拼接 markdown 文本 this.messageBuffer += content; // 拼接好的文本再解析成 html const fullHtml = marked(this.messageBuffer); } 最后利用打字机效果,将 fullHtml 文本慢慢渲染到页面上,我用的是 Vue 开发的,所有渲染 html 很简单,类似这样: <div v-html="answeringText"></div> 到这里,再加上定制的样式,渲染效果是做到了,但又出现另一个问题,就是打字机效果中,会闪现 html 的标签符合,如 <、> 这样,观感会不好。这个问题我在开发完整体功能后尝试解决,但一直没成功。直到快提测前突然想到换种渲染方式看看,于是真给解决了。做法也很简单,就是将 markdown 文本的解析,延后到渲染时,直接在 v-html 指令中解析 markdown,大致流程代码如下: // 接收消息拼接到 messageBuffer,再开启打字机 receiveMessage(content){ this.messageBuffer += content; this.startTypingEffect(); } // 打字机效果简单实现 startTypingEffect() { if (this.typingInterval) return; this.typingInterval = setInterval(() => { if (this.typingIndex < this.messageBuffer.length) { // 分割 markdown 文本 this.answeringText = this.messageBuffer.slice( 0, this.typingIndex + 1 ); this.typingIndex++; } else { clearInterval(this.typingInterval); this.typingInterval = null; } }, 30); } 再渲染: <div v-html="marked(answeringText)"></div> 此时就不会再有 html 标签闪现的问题了。问题的原因我猜测跟之前的分步解析 markdown 问题一样,就是渲染到 < 时还不能完整渲染出 html 内容,只有等渲染到 > 时才算完毕,所以会先闪。但是现在改进的写法,就可以避免分步解析 html 标签符号的问题。 数学公式的解析 AI 的回复内容中会包含数学公式,一开始我没处理,就被测试提出了 bug: 这个问题也好解决,我用的是 markdown-it 来解析 markdown 文本的,它有个插件 markdown-it-katex 就可以解析数学公式。按照文档加入了插件,确实可以正确解析数学公式,但是这个插件需要引入katex 的样式,官方是提供的 cdn 链接,用 link 引入的: <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.css" /> 但正式项目中,我不敢直接用公开的 cdn 连接,于是我直接下载这个样式文件到项目中引入了,但是发现还需要一堆字体文件: 我不想下载这一堆字体,于是换个方法,就是直接 npm 安装 katex 这个插件,然后引入它暴露出的样式即可。但是!这样是不行的。 我不得不说,坑无处不在,npm 安装的 katex 版本是 v0.16.11,但是插件 markdown-it-katex 需要的是 v0.5.1 版本的样式,好,那我直接安装 katex@0.5.1 就好了,但是等我切换版本安装后,骤然发现这个版本的 npm 包根本没暴露样式文件!我真的是大写的无语了,最后我不得不下载了所有的字体,幸好每个字体文件也不大,都是十几 k 大小的,然后下载了 v0.5.1 版本的样式文件到项目中引入。 这个问题看似好解决,但是也耽误了我不少时间,在使用第三方插件包时,还是要注意版本更新问题啊,太久的包还是能不用就不用的好,就比如这个 markdown-it-katex 包最后一次更新还是 8 年前,但奈何周下载量还有几万的,不得不用。 总结 其实这个功能开发中,还有很多小细节,文章里无法细说。只不过这是个好的开始,对于我后续开发类似的功能,有了些许经验。后面我可能会开发一个自己的 AI 助手功能。
 · 10 min read 

解决 Mac 钥匙串访问中证书不受信任问题

开发 IOS 应用时少不了打几次证书,这次重新打包一个好久没动的 app,证书要重新申请,结果在导出 .p12 证书时,出现证书不受信任问题。 这个问题在之前也出现过,当时也解决了,这次又出现让我迷惑了一下,后来想到我不久前重装过几次系统,可能是这个原因导致的,那就重新解决下。 这个问题具体原因说不清,我手动信任证书是没有用的,从各方搜集到的解决方法都是安装根证书,证书下载地址: https://www.apple.com/certificateauthority/ 。需要下载其中的 Worldwide Developer Relations 证书: 有人说要下载全部,但我只下载了 3 个安装上就解决了问题,安装方式是双击。
 · 2 min read 

uniapp 开发记录

去年我用 uniapp 帮公司开发了一个跨平台 App,最近又改版优化了一版。App 虽然简单,但是也踩了不少坑,所以准备用此文记录一下。 初始化项目 uniapp 支持两种初始化项目方式,一个是用他们的编辑器 HBuilderX(后简称 HBX) 可视化创建,一个就是利用 vue-cli 命令行方式创建。但是开发 APP 始终需要用 HBX 来进行云端打包,所以我就直接用 HBX 来初始化项目的。 但是我觉得这个编辑器不好用,所有开发时用 HBX 运行项目,然后在 VSCode 中写代码,并配置了 prettier,保存自动格式化代码。 现在 uniapp 搞了个 HBuilderX cli 命令来运行或者打包 app,我浅看了下,本质还是需要安装 HBuilderX 的,只是可以通过命令来操作 HBX ,没啥意义。文档在这里:HBuilderX cli 我个人更希望 uniapp 官方拥抱 VSCode 这些成熟的编辑器,只专注做 sdk。 开发运行 关于基座 开发 app 时,可以使用模拟器或者真机来调试,此时安装的是基座包。uniapp 提供标准基座,也可以自定义基座。 标准基座使用的是 DCloud 的包名、证书和三方 SDK 配置,是可以直接安装调试的,但是 ios 的真机调试用不了。 只推荐自定义基座,或者只能使用自定义基座,因为可以使所有的 app 配置生效,比如 App 名称和图标,权限设置,三方 sdk 配置等等,都需要生成自定义基座来生效。参考文档:自定义基座 关于证书 开发 App 就一定涉及证书,打自定义基座时也需要证书。只有安卓提供了测试证书,但是最后发布还是需要自定义证书的。安卓和苹果的证书教程: ios 证书,文档:https://ask.dcloud.net.cn/article/152 android 证书,文档:https://ask.dcloud.net.cn/article/35777 关于组件 开发跨平台 App,需要考虑兼容性。官方自带的组件一般是可以兼容 android 和 ios 的。但是如果使用插件时,需要注意下有的插件是针对某一平台开发的,就不会兼容另一个平台。 另外 UI 组件的选择,我这里推荐 uview-ui,比官方出的 uni-ui 好用些,但是只兼容 Vue2。所以开发 App 我建议使用 Vue2 语法版本,因为很多插件都没更新到 Vue3 语法,即使出了也有不少问题。 指定页面调试 有时候开发的页面层级比较深,调试时需要能够直达页面比较快捷。此时可以设置 condition 这个配置项,在 pages.json 中配置页面路径列表,开发时即可选择打开指定页面。如图: 其他 别的关于 app 的配置,参考文档就好,没啥大坑的。 还要提及下,如果新勾选了一些要用到的模块,需要重新打自定义基座并重新安装,这样真机调试才可以看到效果,当然这些都会有提示。 另一个就是配置文件 manifest.json支持可视化修改,也可以支持源码视图修改。如果你用别的编辑器,直接源码视图修改的话,要注意自动格式化的问题。因为这份配置文件里有很多注释,本来 json 文件是不支持这些注释的,在别的编辑器上可能会报错,保存时可能会格式化掉一些内容。 组件自动引用 HBuilderX 提供 easycom 组件模式,即使用组件前不需要手动引用,可以直接使用,只要符合规范的组件都会自动引用。那么这里就有一个坑,一定要严格遵循它的路径规则,即: 安装在项目根目录的 components 目录下,并符合components/组件名称/组件名称.vue 安装在 uni_modules 下,路径为uni_modules/插件ID/components/组件名称/组件名称.vue 我最开始自定义组件目录下用的是 index.vue,导致引用失败。当然这是他们的默认规则,你也可以去 pages.json 中自定义规则。 路由拦截 由于用的不是 vue-router,所以路由拦截就不好实现了。但好在 uniapp 提供了一个 api 拦截器,即 addInterceptor。它的作用是可以拦截 uniapp 中的一些 api,比如 request,navigateTo等。 如果需要拦截路由,拦截 navigateTo 或者 redirectTo 即可,可以在执行 api 之前进行一些判断。示例代码: function addInterceptor() { // 需要拦截的 api 列表 let list = ['navigateTo', 'redirectTo'] // 遍历执行拦截操作 list.forEach((item) => { uni.addInterceptor(item, { invoke(res) { // return true 表示放行,return false 表示不继续执行 if (res.url === '/pages/index/index') { return true } else { // return false 之前可以进行一些操作,比如重定向 return false } }, fail(err) { // 失败回调拦截 console.log(err) } }) }) } 图层层级 这是最坑的一点。开发 App 时,有些组件是原生渲染的,这就意味着无法使用 z-index 控制它的层级,就会出现遮挡内容的情况。 如果你使用 nvue 开发的页面,就没有关系,因为此时页面全是原生渲染的,可以覆盖层级。但是如果你页面已经用 vue 开发完,但是后来加了原生组件的需求,那么就有点难受了。 如果只想在原生组件上覆盖一些内容,那还好做,使用 cover-view 组件可以覆盖内容到原生组件上,具体看文档。 还有一类场景就是原生组件遮挡了页面中的内容,比如页面滑动时遮挡了底部操作栏,popup 弹窗被原生组件遮挡等。这种场景优点难办,但也有办法。一个就是使用 cover-view组件重写被遮挡内容,页面布局有点受限,但基本能实现。另一个办法就是使用原生子窗体 subNVue,关于它的介绍,引用官方定义: subNvue,是 vue 页面的原生子窗体,把 weex 渲染的原生界面当做 vue 页面的子窗体覆盖在页面上。它不是全屏页面,它给 App 平台 vue 页面中的层级覆盖和原生界面自定义提供了更强大和灵活的解决方案。它也不是组件,就是一个原生子窗体。 其实就是一个局部的 nvue 内容,可以覆盖住原生组件。关于它的使用就是需要在项目中创建一个 nvue 组件,然后去 pages.json 中对应的页面配置中配置style -> app-plus -> subNVues即可,可以参考这篇文档:subNVue 原生子窗体开发指南。 待续 后面想起来再加~