接前言遥遥领先!古茗门店菜单智能化的探索 - 掘金,在这篇文章中描述了关于电子菜单业务的总结,方便各位理解电子菜单的业务主要在做什么。
那么各位熟知的在门店中除了电子菜单屏,那么还有电视机作为广告位来展示宣传视频,限时营销商品等等,在上一期中基于电子菜单的诉求,产生了自定义建站、定制化通用物料的等等需求,于是产出了后台建站设计方案与页面渲染的技术方案。
在后续的电视机业务中整体的流程还是十分相似,但由于实际业务不同,原有逻辑也会存在着多态。所以在做电视机业务中,只是将电子菜单的代码拷贝出了一份,在原有逻辑基础上修修补补,最终产出出了一份针对电视机业务的代码。
过早优化是万恶之源,但如果再来一版建站需求,仍然只是拷贝一份代码缝缝补补长久看来肯定是不行的,容易在原有基础上产生多种不同的代码风格,后续的维护难度增加。因此,封装SDK来保证整个项目的工程化设计合理与高可拓展性,来支持快速搭建建站场景与针对当前建站功能模块的改进是非常必要的。
业务架构图画板
在优化过后,我们在设计层面进行了分层解耦,引擎/生态/端上之间互不影响,在低代码引擎中提供基础搭建协议来统一自定义规范。引擎的拓展生态中提供了基础常用的插件与组件,且可通过自定义组合基础组件来自定义拓展生态。端上H5提供了最基本的离线缓存能力,端上稳定性监控能力等等。通过这样一套层级架构,各层之间统一规范互不影响,组成完全可以满足业务需求的低代码平台。
业务拆分/功能拆分我们常见的低代码编辑器大多数为这种结构,果然好的设计总是心有灵犀,在后续的拆分中也会是主要围绕这四块区域来思考如何提高复用性与完善当前现有设计的不足。
相比以往代码实现的构成,将其区域拆分更加细致,主要会拆分为下面这些布局组件:
Store:设计器的全局状态管理器,用于提供设计器核心上下文环境,包含可视化设计器实例,定义事件Map,当前选中的组件实例等等。DesignFrame:主框架组件,提供布局结构,开发者可根据设计稿来自行选择使用其他功能结构。ActionBar:工具栏组件,提供工具栏基本功能,与对应位置的插槽。DragPannel:物料区侧边栏组件,提供基础的原子组件/业务组件/物料市场TabItem,可根据配置自选。DesignPanel:可视化设计区组件,支持事件回调自定义,框选,旋转,缩放,节点交互等等自定义配置。SettingPanel:表单配置面板组件,配置改变时进行value2node,监听节点改变时进行node2value等等操作通过Layout布局将布局组件可进行自由组合,细拆分组件来隔离拆分功能。
物料区如何实现物料互通由于业务和产品的差异,在多业务产品设计中未考虑物料的复用,导致物料区不具通用性。这在技术层面上造成了物料组件的重复设计,导致不同业务中使用的npm包实现了重复的物料组件。
为了解决这一问题,可以从以下两方面入手:
自定义组件的创建:通过基础组件构建自定义组件,并将这些自定义组件纳入物料市场,从而进一步提升复用性。搭建物料市场:实现基础组件的互通,促进不同业务之间的物料共享与复用。物料市场在之前的讨论中,不同业务使用相同类型的组件,由于物料组件都被划分到各自业务的物料 npm 包中,这种情况我们想要拆分+使用,第一种方案是针对基础组件包抽离出单独的npm包/业务组件单独抽出npm包这种方案,第二种方案就是我们所想要实现的物料市场,这样多业务都可以去共享物料市场而不需要去额外开发,每个产品想搭建自己的低代码平台只需要关注需要新增哪些业务组件,以及当前的基础组件是否支持当前业务即可,从而减少额外的心智负担。
支持自定义组合基础物料
不同于表单类型的低代码平台,我们业务在端上展示的内容离不开两种基础元素:图片和文字。这两个基础物料是必选的。考虑到不同业务场景的多样需求,除了基础的图片与文字组件,针对业务中出现的一些通用场景,我们还需要提供一些定制的物料组件。这些组件以大颗粒度的形式进行封装,更符合业务的使用场景。
在提供定制的物料组件之前我们也思考过,既然当前所有的设计稿都是由文字与图片这些原子组件形成的,是否可以直接让业务方通过组合这些原子组件来实现所需功能,而无需实现定制物料?但思考后觉得,不是不能实现而是实现太复杂,且实现出来上手成本巨高巨难用。第一点是我们的存在着较多且复杂的联动逻辑,只针对这两种原子组件去处理联动配置,代码复杂性提升一个level,运营上手操作体验可能极差。第二个是在迭代组件时,相对影响开发效率(不如新开发一个物料效率更高),后续难以维护和更新。第三点是定制组件可以根据业务需求进行扩展和定制,而简单的组合方式可能限制了未来功能的扩展和创新。
所以我们在图片与文字基础物料组件中,会额外会提供业务需要的定制组件来供其进行组合,每组合创建出的一个物料,都是一种新的可能。通过开放基础物料进行组合的口子提供给业务方,来增加创建物料的灵活性。
如何实现物料市场要实现落地物料市场的组件时,需要从两个源头考虑,一个是业务通过自定义组合基础物料组件来实现的定制化物料,第二个是多业务共用的物料。对于多业务公用的物料通过预置或在DragPannel物料区组件中进行配置,公用的物料组件统一抽离在Npm包中进行预置。而业务定制化的物料实现起来略有不同,首先存储并不通过Npm包进行抽离,而是使用自定义的Schema进行端上存储,其次在创建时为了避免污染其他业务域所以在创建的时候加入了业务域分类,防止视觉污染。
如何实现业务进行定制化物料呢?通过定义N多基础物料组件,在设计区中自由编排,编排完毕后我们就得到了一份Schema,这份Schema中包含着每个基础组件的位置信息、宽高、还有配置信息等等。创建时会关联这份Schema来作为定义物料的实体,在之前的文章中我们说到,在画布中我们最终是通过Svg的foreignObject标签来实现的,**View = Fn(props),**这个时候我们只需要实现可以将这份Schema渲染出的组件,并将这份Schema作为的传参传入,就可以无需差异化设计来实现与业务组件的拖拽创建等一系列流程。
如何避免Schema不兼容在物料的迭代中,经常会进行字段的新增与修改,对物料组件的每次改版都会存在与之前版本Schema不兼容的可能。如果不兼容可能会出现页面白屏的影响。
增加错误边界组件。为物料组件引入错误边界组件,以防止由于旧版本 Schema 缺失字段而导致的引用错误,从而引发整个页面崩溃。错误边界可以捕获这些错误并提供友好的提示,确保用户体验影响面降低。组件传参增加默认值。新增和删除字段,如果在物料组件没有使用?.而是强引用字段,在兼容老Schema时候非常容易字段缺失出现报错,在传参时尽量使用默认值兜底。维护changelog。确保每次物料组件的修改都有对应的changelog。更新描述包含功能变更,新增字段的含义,变更带来的影响等等版本控制:物料组件定义版本号, Schema 中加入版本字段,增加逻辑针对对应的版本号做额外的兼容。可视化设计区插件化在可视化设计区会有一些共同的操作,比如键盘监听,鼠标操作,节点移动,画布缩放,历史操作回退等等操作,之前的代码会将所有实现全部杂糅在一起,缺失代码可读性。通过将可视化设计区抽为插件化,整体代码更加利于代码模块化。
例子:比如实现move-plugin,插件的功能是监听键盘事件,通过上下左右键来移动x像素的距离与监听节点的移动,当节点移动时更新value的坐标属性。那么他的变化就可以从一堆代码抽离成可插拔的组件。如下图:
从上图可以看出,原本杂糅在一起的功能代码通过插件化方式被独立拆分,通过这种方式可以将原来的实现进行解耦,后续拓展新功能时只需要实现对应的插件规范进行接入即可。
页面管理与图层结构在进行复杂的交互时,选中子组件/母组件图层下方组件时,操作繁琐,且子母组件在拖拽时显示关系不明确,故增加图层结构图作为通用项,显示当前所有物料中所有依赖关系,来提供清晰的布局框架,方便组织和安排页面元素。在实现方面也比较简单,导出当前页面中的所有元素,使用DFS深度优先导出遍历出一份组件ID以及页面所需要展示的数据结构即可,渲染布局框架后,再实现图层-画布节点联动,显隐节点等等功能。
属性设置(属性设计器)设计器主要用于设置低代码组件的属性值,由于业务产出的模版只会在对应的电视机/电子屏等终端设备中展示,不存在与用户进行交互的场景,故不需要额外设计Action来支持事件设置/事件绑定等等。主要思考表单组件复用和数据绑定层面。
表单渲染配置化我们常用的属性配置项,大概有数值,文本,布尔,单选,多选,日期等等基础属性值,还有复合对象,复合对象数组等复合属性值,对于组件所需要的值来说,是需要多个属性Setter来组合配置成,在原有的写法中是通过在代码中叠组件的形式来实现展示表单渲染区,而在后续的写法改为通过JSON Schema配置出一套表单渲染区域,JSON Schema配置前期开发成本高,但后期维护可以降低成本低。例如:
{ "name": "PictureGroup", "title": "图片组", "configure": [{ "type": "title", "name": "图片设置(px)" }, { "type": "formItem", "props": { "name": "size", "noStyle": true, "component": "WidthAndHeightForm" } } ]}对于基础属性值可以直接拿Antd组件来支持,例如UploadSetter,CheckBoxSetter。而复合属性值可以通过预设自定义的表单组件来配置,例如SizeSetter、FontSetter等等,在定义出JSONSchema后通过一套表单渲染器来实现物料组件的属性配置。
复合表单复用与组件化数据带动视图更新会通过监听表单组件的数据变更来触发,但由于多业务的代码目录隔离,相同或类似的表单组件会在不同的代码目录中重复实现,长久以往维护成本与一致性问题会带来一定的痛点,且如果涉及多成员开发也会导致一定的开发效率低下。所以决定通过npm包将通用表单组件抽离在一起,这样不仅可以统一代码风格与规范,还能集中管理和维护文档的更新,从而提高开发效率和代码质量。
在抽复合表单组件的同时,需要同步设计师,将现有设计的复合表单组件整理并形成Figma团队组件库。避免相似功能的重复设计,减少维护成本。
端上逻辑数据请求方式变化之前获取数据的逻辑,是通过每分钟的轮询接口来获取数据的差异性变更,这样下来请求量飙升,且绝大部分是无效请求。所以我们将其改造为长轮询+流式传输,通过这种方式进行请求时,服务端收到请求后,不会直接响应,而是将其挂起,当有数据变更时才会返回数据。为了减少响应数据体积,通过这种方式请求到的数据主要为MQ消息变更,通过区分消息类型来触发请求动作。
由于浏览器提供的EventSource对象能力有限,于是我们针对长轮询+流式传输的请求方式封装了自己的SDK,在使用层面只需要传入基本属性与要监听的topic,直接在回调监听消息的变更来处理请求动作即可,为防止同一时间收到消息的设备同时开始任务(比如请求接口),增加了在随机1分钟内的收集MQ消息进行延时请求来达到消峰的目的。
const initMq = () => { const mq = createMQ({ accessKey: mqServer.accessKey, topicSet: ['content-changed', 'schedule-changed'], logShow: false, ... }); let changeContent = []; let timeoutId = null; mq.onMessage((data) => { try { data?.forEach((message) => { changeContent.push(message); }); // 消峰 const delay = Math.floor(5000 + Math.random() * 55000); if (changeContent.length) { if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => { handleContentChange(changeContent); changeContent = []; }, delay); } } catch (e) {...} }); mq.onError((err) => {...}); // mq重试 registerWithRetry(mq, logger, { shopCode, screenNum });};消息通信架构图在浏览器端支持会订阅多个生产者服务的消息,将其推入任务队列中进行处理。业务场景中不会只与业务数据的服务进行通信,也会存在工程版本的服务进行通信的需求,随着工程的逐渐迭代可能会增加对额外服务的订阅,所以通过WebWorker单启线程将其设计为多线程处理,带来了高拓展性。
收益相比较轮询的方式,在设计中更符合事件驱动型架构。减少了不必要的请求(只有接受到消息才会进行请求),服务器负载有效降低,在3w+电视机屏幕的业务场景中,减少了服务器处理冗余请求的压力。相比较之前每1分钟的请求改变为了1分钟内随机时延进行消息聚合,相比改变后的实时性效果不太明显,但随着轮询时间的增加实时性的效果会有显著趋势。资源展示稳定性视频资源缓存由于门店的网络差异性比较大,存在小部分门店网络是非常差的情况,所以这种情况下正常展示素材/兜底素材是非常必要的。我们做了如下逻辑:
降低请求数据量,图片/视频资源的上传压缩接受数据变更后展示过渡态进行资源预加载无网络时资源请求,将其收集到队列,有网络重新发起。在原资源缓存方案中,Video进行请求会经过ServiceWork的拦截,通过监听请求成功后的回调来将资源存储进CacheStorage中,但如果门店网络出现多次断连的情况,可能会缓存一个错误的结果,这个错误结果导致通过标签的请求返回值一直报错。
所以我们对资源缓存机制进行了优化,以应对门店网络不稳定的情况,由于src请求视频时会进行边加载边播放,通过标签判断资源是否完全加载不可控,所以改为不通过标签的src属性进行发起请求,而是通过fetch请求加载,资源加载完毕后展示对应的资源并存储CacheStorage中,对于视频的判断完整加载的处理逻辑如下:
fetch(url.replace('http://', 'https://')) .then(async (res) => { const getReader = async (reader, chunk, contentLength, receivedLength) => { const { done, value } = await reader.read(); if (done) { const blob = new Blob(chunk, { type: 'video/mp4' }); const ramUrl = URL.createObjectURL(blob); return ramUrl } chunk.push(value); receivedLength += value.length; if (contentLength && receivedLength === contentLength) { const blob = new Blob(chunk, { type: 'video/mp4' }); const ramUrl = URL.createObjectURL(blob); return ramUrl } return getReader(reader, chunk, contentLength, receivedLength); }; if (res.headers.get('Content-Type')?.indexOf('video') !== -1) { const reader = res.body?.getReader(); const contentLength = Number(res.headers.get('Content-Length')); const chunk = []; return getReader(reader, chunk, contentLength, 0); } }) .catch((error) => { console.error('There has been a problem with your fetch operation:', error); return MaterialStatus.FAIL; });资源请求轮询监听判断标签请求的全局报错,网络类型的报错会将其添加到队列中,待有网络时重新刷新队列
let eventQueue = [];let isOnlineRef = false// 判断是否有网,无网络window.ononline = function () { isOnlineRef = true; if (eventQueue.length) { reloadImgQueue(); }};window.onoffline = function () { isOnlineRef = false;};// 监听资源报错window.addEventListener( 'error', function (e: any) { // 当前dom节点 const { tagName, src, baseURI } = e.target; const upperCaseTagName = tagName?.toUpperCase(); if (upperCaseTagName === 'IMG') { if (!src || src === baseURI) return; // 无网络时,收集事件进队列 if (!isOnlineRef) { eventQueue.push(e); return; } setTimeout(() => { // 重新赋值引起重新请求 e.target.src = src; }, 5000); } }, true);// 刷新队列中的资源const reloadImgQueue = () => { const queue = eventQueue; eventQueue = []; queue.forEach((e) => { setTimeout(() => { // 重赋值进行重新请求 e.target.src = e.target.src; e.target.addEventListener('load', () => { // 加载完毕后从移除队列 queue.splice(queue.indexOf(e), 1); }); }, 5000); });};总结线下门店的电视机屏与电子菜单屏是透出营销活动、展示品牌调性的有效资源位,在优化运营流程,提高运营效率,增强端上稳定性是一直在追求的事情,我们不会重复"造轮子",而是致力于真正能够能解决业务难点,开发痛点的解决方案。
本文中我们详细介绍了古茗电子屏设计与改进的关键技术方案。在古茗,类似的低代码/无代码建站的业务场景很多,因为业务与场景设计不同,在实现类似业务场景会有不同的技术方案,如何提高设计拓展性进行统一技术工具进而实现落地多种业务也是设计与改进的核心目标。
作者:刘哲
来源-微信公众号:Goodme前端团队
出处:https://mp.weixin.qq.com/s/W5fONHJQY2l45tWMqX7DOg