“360 AI音箱”即将发布,移动应用也在紧张有序地开发中。本文将介绍“360 AI音箱”移动应用H5部分的实践,主要包括:
- 项目环境搭建
- 与Native交互
- 自定义中文字体
- 表单输入
- Docker部署
应用主要分4大版块:
- 内容:音箱可以播放的音乐、故事、有声书等等
- 技能:运营预配置的音箱指令
- 场景:用户自定义的音箱指令
- 我的:用户的智能设备、账号等
其中,“技能”和“场景”版块由H5制作。如下图所示,技能部分主要包括运营后端预配置的指令列表和详情两个页面。
注意:本文图片为“360 AI音箱”版权所有。另外,因为是设计稿截图,应用实际发布后的外观可能会有所不同。
技能列表和详情主要涉及使用自定义字体:Adobe思源宋体(https://source.typekit.com/source-han-serif/cn/)。
场景,其实是用户自定义的技能。场景相对复杂,不仅涉及类似技能的展示,还涉及增、删、改,甚至还有与原生配合的闹钟场景。
前端H5的技术方案有单页SPA、传统B/S架构。考虑到项目涉及使用自定义字体和保存自定义场景的中间结果,所以采用了传统B/S架构,以最大限度避免加载Web字体的FOIT/FOUT(https://www.zachleat.com/web/fout-vs-foit/),同时利用服务端缓存。
开发框架:
- ThinkJS:360优秀的开源服务端Node.js框架(https://thinkjs.org/)
- Webpack:前端编译打包(https://webpack.js.org/)
- Vue.js:SPA组件式开发框架(https://vuejs.org/)
项目代码结构如下:
其中,
- deploy:是部署脚本和Docker构建脚本所在目录
- frontend:是前端资源目录,主要是Webpack编译入口文件、模板文件及JS和CSS资源
- runtime:是ThinkJS运行时存放配置等信息目录
- src:是服务端源代码目录
- view:是服务端模板目录,模板文件由Webpack编译保存过来
- www:是服务端静态资源目录,比如Webpack打包后的JS、CSS、图片、字体等
实际项目中的静态资源都使用Webpack插件直接上传CDN,图片也直接引用CDN图片,因此服务端并不保存任何静态文件。
H5与Native共同定义了两个接口,用于双方互相调用。
// JS调用传参,通过参数把数据传给Native
SpkNativeBridge.callNative({action, params, whoCare})
接口说明:
SpkNativeBridge
是iOS和Android实现并注入到WebView中的接口对象callNative
是SpkNativeBridge
的方法
参数说明:
action
:字符串,希望Native执行的操作params
:JSON对象,要传给Native的数据whoCare
:数值,表示JS希望哪个端响应- 0:iOS和Android都响应(默认值)
- 1:iOS响应
- 2:Android响应
返回值:具体商定
// Native调用传参,通过参数把数据传给JS
SpkJSBridage.callJS({action, params, whoAmI})
接口说明:
SpkJSBridage
是JS在WebView中实现并暴露的接口对象callJS
是SpkJSBridage
的方法
参数说明:
action
:字符串,希望JS执行的操作params
:JSON对象,要传给JS的数据whoAmI
:数值,表示哪个端调用的- 1:iOS调用的
- 2:Android调用的
返回值:具体商定
下面通过两个例子来说明H5与Native如何使用上述接口交互。下面这张图是用户创建场景期间退出时原生要弹出确认框的情景:
如图所示,导航条是Native,下面是WebView。导航条上的返回和保存按钮需要H5根据场景内容控制。比如,如上图所示,用户有未保存的内容时点击了返回按钮,H5要告诉Native是否可以返回,还是需要提示。交互过程如下:
Native调用
window.SpkJSBridge.callJS({
action: "can_back",
params: {},
whoAmI: 1/2
})
H5返回值
{
can: false,
target: "prev"
}
返回值说明:
- can: 布尔值,true 表示可以返回,false 表示需要弹确认框
- target: 字符串,"prev" 返回上一级, "top" 返回顶级,"closeweb" 关闭之前的webview
换句话说,用户点击Native的“返回”按钮,Native调用JS的can_back()
方法,JS判断是否有未保存内容,如果有则返回上述值,通知Native弹确认框。
除了“返回”,还有“保存”按钮。H5负责控制“保存”按钮是否启用,以及启用之后用户点击调用的方法。具体来说,每次WebView加载后,JS判断是否可以保存,比如上图中场景只有“对音箱说”的部分,没有“音箱的回应”,不能保存,因此H5会调用Native的displayRightButton()
方法,告诉Native按钮文字、是否启用,以及启用后用户点击的回调函数:
window.SpkNativeBridge.callNative({
action: "displayRightButton",
params: {
name: "下一步",
enable: false,
callbackName: "scene_topic_save"
},
whoCare: 0
})
H5调Native时传参,如果调Andoid则只能传基本数据类型(字符串或数值),不能传JSON对象;iOS则没问题。为此,H5需要判断WebView环境是Android还是iOS,如果是前者,则将JSON对象转换为字符串:
// 包装方法,对Android将JSON转换为字符串
window.callNative = (param) => {
if (speakerWebviewHost === 2) param = JSON.stringify(param)
window.SpkNativeBridge.callNative(param)
}
另外,JS方法向Native返回值必须同步返回,虽然Native调用JS是异步调用,但JS如果返回Promise,Native是无法处理的。因此,需要在使用XMLHttpRequest
对象时将async
参数设置为false
:
const scene = $.ajax({
url,
async: false
}).responseJSON
最后,还有一个“坑”:Android如果未开启WebView的localStorage特性,使用localStorage的H5页面就会“冻结”!
如前所述,“技能”列表和详情都需要用到Adobe开源的“思源宋体”,而且原生闹钟等也会用到该特殊字体:
图中的“玩法介绍”“功能介绍”的标题以及前者的内容都需要使用自定义中文字体。然而,设计师给的开源字体文件有23 MB这么大,包含65000多字符。考虑到技能和闹钟用到这个字体的字符有限,我们决定使用字体截取技术。
经过调研,并且考虑到技能列表需要动态截取,最终我们自建了一个字体服务:奇字库。“奇字库”提供中文字体在线动态截取服务,让字体文件从十几MB瞬间变成十几KB、几KB;基于Adobe和Google共同开发的Web Font Loader(https://github.com/typekit/webfontloader)定制了加载脚本,实现了字体加载与应用完全自动化。
目前,“奇字库”囊括了公司所有付费的版权字体,可供公司内部各业务线的各类Web或客户端项目使用:
为获得最佳用户体验,“奇字库”提供了丰富的接口,可满足灵活定制的需求。服务端或浏览器可以通过API调用,动态截取字体。共有两大类共8个�API:第一类是�获取�字体“URL”的,包括上传到CDN的URL和base64格式的Data URL;第二类是获取CSS @font-face
规则文本的,包括获取CDN URL和Data URL内容的CSS。
比如,获取字体的CDN URL,API返回结果示例如下:
{
"ttf": "//s3.ssl.qhres.com/static/b73305c8dde4d68e.ttf",
"woff": "//s1.ssl.qhres.com/static/e702cca6e68ab80a.woff",
"woff2": "//s1.ssl.qhres.com/static/e27f2a98e5baf04d.woff2",
"eot": "//s2.ssl.qhres.com/static/590b2e87fb74c9d6.eot"
}
再比如,获取字体Data URL的CSS @font-face规则,API返回的结果示例如下:
@font-face {
font-family: myWebFont;
src: url('data:font/opentype;base64,Fg8AA...8AAw==');
src: url('data:font/opentype;base64,Fg8AA...8AAw==?#iefix') format('embedded-opentype'),
url('data:font/opentype;base64,d09GM...AAA==') format('woff2'),
url('data:font/opentype;base64,d09GR...Ssw==') format('woff'),
url('data:font/opentype;base64,AAEAA...//wAD') format('truetype');
}
最简单的方式就是在网页里直接复制粘贴代码:
不过因为我们的项目有服务端,所以可以将截取到的字体文件与网页及CSS一起下发到浏览器,从而完全避免FOUT,实现与使用本地字体一样的用户体验。
“奇字库”目前只是360内部的项目,仅对公司内部提供服务,外部无法使用。如果读者对字体截取有兴趣,可以参考笔者之前的文章“前端字体截取:实战篇”(https://mp.weixin.qq.com/s/pq9hXz_iGwADNjNAWWoK-g)。
表单输入的重点,一是组件化输入框,便于添加和删除;二是对用户输入的计数,涉及composition*
事件;三是使用debounce
,避免过早对用户输入进行计数。
首先,为满足用户输入多条“音箱回应”及自定义占位符文本的需求,输入框使用了contenteditable
值为true
的div
元素:
<div class="fieldset">
<div class="placeholder">输入想让音箱说的话<small>/最多{{lengthLimit}}字</small></div>
<div class="input" contenteditable="true"></div>
<img class="clearReplyInput" src="//p0.ssl.qhimg.com/d/lisongfeng/icon_close_s.png">
<span class="countDown">0</span>
</div>
而且,基于这个元素构建了前端组件:
import inputComponent from './_replyInputComponent'
每次创建这个组件的新实例,就会自动在DOM上添加新的输入框:
// +继续添加
new inputComponent({formsSelector, formTemplate, lengthLimit})
其次,是对用度输入的字符数进行计数。此时,要用到三个事件:
keyup
:用户触摸软键盘按键后触发compositionstart
:用户调用输入法开始输入一段文字时触发,类似keydown
compositionend
:用户选取了最终要输入的文本结束一次输入时触发,比如用户使用拼音或五笔输入法,之前的输入比如“jiang ge gu shi”只是“中间输入”,不会触发这个事件,只有当用户最终选取了“讲个故事”之后才会触发
但是,compositionend
不能识别英文的“组词输入”,所以最终还是绑定了keyup
事件:
// 开始输入隐藏占位符提示
this.input.bind('compositionstart', e => {
this.placeholder.hide()
})
// 组词结束后,处理内容并绑定input事件
this.input.bind('keyup compositionend', inputHandler)
最后,就是使用debounce
对用户输入事件做延迟处理:
import debounce from 'lodash.debounce'
// 输入检查
const inputHandler = debounce(e=>{
// ...
}, 300)
很多人不清楚debounce
和throttle
的区别。
debounce
是在某事件至少停止触发多长时间后执行,比如上面的inputHandler
会在我们注册的事件(keyup compositionend
)至少间隔300毫秒才会触发throttle
是针对连续密集触发的一系列事件,比如scroll
或resize
,将它们“节流”为均匀地每过多长时间才触发一次。
这里使用debounce
包装实际的处理程序,是为了避免过早地在用户输入期间对输入进行计数。
容器部署的优点是多机房灾备,某机房因切割或服务下架而停服,都不会影响线上服务。容器部署用到的是360 HULK云平台的容器相关服务Stark:
Docke部署流程如下:
- 本地构建Docker镜像
- 上传到Stark
- 修改容器镜像
- 重启服务
关于使用Docker,主要看看官方的Get Started和Dockerfile相关文档即可
- Get Started:https://docs.docker.com/get-started/
- Dockerfile reference:https://docs.docker.com/engine/reference/builder/
本文又是一篇“急就章”,大略介绍了360 AI音箱H5开发过程中的一些基本实践,希望可以为同行提供一些参考和借鉴,也欢迎大家批评指正。另外,开发过程中还有一些涉及算法的有意思的技术点,同样值得分享,等项目上线之后有时间了再分享吧。