“360 AI音箱”即将发布,移动应用也在紧张有序地开发中。本文将介绍“360 AI音箱”移动应用H5部分的实践,主要包括:

  • 项目环境搭建
  • 与Native交互
  • 自定义中文字体
  • 表单输入
  • Docker部署

360 AI音箱应用H5部分简介

应用主要分4大版块:

  1. 内容:音箱可以播放的音乐、故事、有声书等等
  2. 技能:运营预配置的音箱指令
  3. 场景:用户自定义的音箱指令
  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/),同时利用服务端缓存。

开发框架:

项目代码结构如下:

其中,

  • deploy:是部署脚本和Docker构建脚本所在目录
  • frontend:是前端资源目录,主要是Webpack编译入口文件、模板文件及JS和CSS资源
  • runtime:是ThinkJS运行时存放配置等信息目录
  • src:是服务端源代码目录
  • view:是服务端模板目录,模板文件由Webpack编译保存过来
  • www:是服务端静态资源目录,比如Webpack打包后的JS、CSS、图片、字体等

实际项目中的静态资源都使用Webpack插件直接上传CDN,图片也直接引用CDN图片,因此服务端并不保存任何静态文件。

与Native交互

H5与Native共同定义了两个接口,用于双方互相调用。

1. JS调Native

// JS调用传参,通过参数把数据传给Native
SpkNativeBridge.callNative({action, params, whoCare})

接口说明:

  • SpkNativeBridge是iOS和Android实现并注入到WebView中的接口对象
  • callNativeSpkNativeBridge的方法

参数说明:

  • action:字符串,希望Native执行的操作
  • params:JSON对象,要传给Native的数据
  • whoCare:数值,表示JS希望哪个端响应

    • 0:iOS和Android都响应(默认值)
    • 1:iOS响应
    • 2:Android响应

返回值:具体商定

2. Native调JS

// Native调用传参,通过参数把数据传给JS
SpkJSBridage.callJS({action, params, whoAmI})

接口说明:

  • SpkJSBridage是JS在WebView中实现并暴露的接口对象
  • callJSSpkJSBridage的方法

参数说明:

  • action:字符串,希望JS执行的操作
  • params:JSON对象,要传给JS的数据
  • whoAmI:数值,表示哪个端调用的

    • 1:iOS调用的
    • 2:Android调用的

返回值:具体商定

3. 调用示例

下面通过两个例子来说明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
})

4. 注意事项

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值为truediv元素:

<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)

很多人不清楚debouncethrottle的区别。

  • debounce是在某事件至少停止触发多长时间后执行,比如上面的inputHandler会在我们注册的事件(keyup compositionend)至少间隔300毫秒才会触发
  • throttle是针对连续密集触发的一系列事件,比如scrollresize,将它们“节流”为均匀地每过多长时间才触发一次。

这里使用debounce包装实际的处理程序,是为了避免过早地在用户输入期间对输入进行计数。

Docker部署

容器部署的优点是多机房灾备,某机房因切割或服务下架而停服,都不会影响线上服务。容器部署用到的是360 HULK云平台的容器相关服务Stark:

Docke部署流程如下:

  1. 本地构建Docker镜像
  2. 上传到Stark
  3. 修改容器镜像
  4. 重启服务

关于使用Docker,主要看看官方的Get Started和Dockerfile相关文档即可

小结

本文又是一篇“急就章”,大略介绍了360 AI音箱H5开发过程中的一些基本实践,希望可以为同行提供一些参考和借鉴,也欢迎大家批评指正。另外,开发过程中还有一些涉及算法的有意思的技术点,同样值得分享,等项目上线之后有时间了再分享吧。

标签: mobile, h5, development

添加新评论