作者:马赟 阿里云钉钉业务平台团队
技术人应当发挥对业务前瞻性的理解,好的架构设计背后一定是对于业务的高度认知与抽象,过程中要对业务关键指标有正确的理解,而不是简单纯功能的堆砌。
钉钉新版协作Tab作为千万级访问量下前端新应用,从我主导前端迭代至今已经有大半年,面对大流量下的高性能和稳定性的压力、复杂前端交互的设计实现、前端技术视角结合业务的高可扩展性架构设计挑战,从0到1完成了三个大版本的迭代,联合客户端和小程序容器完成性能及稳定性方案落地、跨业务线承接了20多种卡片类型,提供了稳定体验的场域服务,同时在研发机制保障下做到全年0客诉和0回滚。
本篇将从产品能力升级背后的前端技术支撑和技术视角性能体验优化及稳定性建设两个方面讲述新版协作前端建设的过程。
相比旧版本以应用为中心的钉钉协作Tab,新版本产品设计理念发生变化:组织管理与激发个体上下共生。协作以个人为中心,聚合与“我”有关的待办、审批、日程、文档等由钉钉各一、三方应用产生的事件,帮助用户更高效地处理与我有关的事件,获得更高效的协作体验,协作通过聚合用户的协作事件,帮助个人提效,进而提升组织效率。
协作前端进化论:
新版协作进化历程
before:旧版本
以应用为中心
after:协作新版
第一版,以时间为中心,强调顺序。
第二版本,以事件为中心,强调操作。
第三版,以人为中心,强调关系。呼应主题,工作版“今日头条”。
很多时候我们难以从产品最初想到完整产品终态,但是如果我们用产品本身的目标和用户价值反推产品的设计和技术实现的时候我们就可以做出相应的改变。
卡片第一版到第三版迭代建设过程中通过验证有效性及价值递增持续做出产品上的调整和优化。
在做第一版卡片实现的时候就和产品对过后续产品能力上的规划,卡片UI内容的新增删除这里都是更新频率比较高的,同时卡片的逻辑层面的功能也是需要不断建设,比如操作按钮、卡片点击事件、点击后的打开方式、卡片的删除置顶等功能等。在这个背景下我决定这样设计:将卡片频繁变更的UI层和不会变更的卡片功能逻辑层抽象出来分开建设,这样即能保证卡片功能持续建设的过程中不需要频繁变更,又能满足卡片UI持续迭代。
将卡片UI层进行布局维度的分层设计:
前端组件结构化事件卡片
//卡片引用
//卡片头部第一层 事项来源和事项时间
//卡片内事项标题 事项描述
//互动卡片SDK小程序插件引用条件渲染
// 卡片补充3C推荐内容
// ButtonList 卡片响应动作
//卡片折叠 递归引用 此处不需要UI变更
这样有个好处就是代码结构清晰直接可以从代码脑海构思出布局,无论卡片内容如何变化,我都可以通过调整对应的组件快速支持产品上做出的调整需求。
应用场景:目前钉钉内20+种通用化卡片展示场景支持。
卡片内容操作:jsapi类型(发起Ding催办等) 跳转类型(半屏等卡片详情)。
卡片本身操作:卡片删除、卡片置顶,卡片折叠展开。
更新操作:点击操作卡片后的卡片内容更新,如日程接受后变成已完成。
这里需要注意的是卡片功能建设过程中业务结合代码的可扩展性,纵使卡片UI怎么调整,但是功能逻辑层是不需要变更。在协作应用的发起协同请求到协同结束的整个过程中,通过分角色分场景的信息设计,在协作流中通过发送消息或沉淀信息的方式,在关键协同节点向用户提供最有价值的协同信息。
删除、更新、置顶等场景下 配合小程序 $spliceData 前端本地列表无感更新
modifyFeedFlowsCommon(
flowId: string,
type: string,
targetName: string,
feedFlows: IComponentInfo[],
newFeed?: IComponentData
) {
// step1 找到对应卡片在协作卡片或子卡片(折叠卡片)中的index
const feedFlowsIndex = this.findFeedFlowsIndex(flowId, feedFlows);
// step2 通过小程序提供的$spliceData函数进行局部更新
if (feedFlowsIndex) {
this.modifyData(targetName, type, feedFlows, feedFlowsIndex, newFeed);
}
},
modifyData(
targetName: string,
type: string,
feedFlows: IComponentInfo[],
feedFlowsIndex: IFeedFlowIndex,
newFeed?: IComponentData
) {
// 分发操作事件,局部更新
this.$spliceData({
[targetName]: [parentIndex, 1, newFeedItem],
});
}
},
为什么要集成互动卡片:这里可以参考协作服务端架构设计的推拉模式,假设我们想要将更多和用户有关的复杂场景都流入到协作里,并且保证同样的卡片在其他地方也能同时投放,互动卡片是必然的。
协作中有两种卡片类型,第一种是上述的标准业务卡片,第二种是可以支持钉钉跨场域投放的互动卡片。
钉钉标准互动卡片是钉钉通用的卡片类型,会针对不同的投放场景进行能力与样式的自适应,确保同一个卡片模板在聊天消息、群聊吊顶、协作、工作台等场景中拥有一致的使用体验。
钉钉互动卡片搭建链路+投放链路示意
卡片本身由两部分组成:数据+模板。数据依靠同步协议来实现动态更新,而模板则是通过小程序包的动态下发和更新机制来实现动态更新的。
协作内部实现以及协作业务场景的特殊性数据差异:卡片数据由两部分组成,业务本身提供的数据源如上图中的事项标题及事项描述,和协作自己的数据源3C推荐理由以事项来源和时间。
在这个差异基础上我们采用业务卡片嵌入互动卡片的形式进行渲染,我称之它为业务套壳互动卡片,这样在满足业务场景的同时能够将互动卡片的内容最大程度的多场域化应用。
内部实现第一步加载小程序小程序包,这体现在当客户端在加载一个卡片模板时,本质上它是在加载一个小程序包,小程序包里面则包含了卡片模板资源。
// 互动卡片插件初始化
initCard() {
try {
if (dd && dd.loadPlugin) {
dd.loadPlugin({
plugin: '5000000002721132@*',
success: (res: any) => {
this.setData({ isReady: true });
},
fail: (err: any) => {
logErrorUser('插件加载失败',JSON.stringify(err))
},
});
app.globalData.hasInitCard = true;
} else {
logApiError('dd.loadPlugin', Date.now(), {}, 'dd.loadPlugin load fail', 'jaspi')
}
} catch(e) {
logApiError('initCard fail', e);
}
},
第二步小程序插件引用卡片SDK
同一张互动卡片投放在不同的场域,如何实现区分?
卡片组件 context 增加 trackingRuleModel 入参。在渲染过程中透传这个参数。
有没有一种可能协作只作为一个场域,你看到的协作feeds流完全由互动卡片来承接?可以。
互动卡片在协作中的应用
番茄表单、云上管车等ISV应用,与正常卡片融为一体,浑然天成,毫无违和感。未来更多的一方二方三方互动卡片都可以流入到协作中。
番茄表单互动卡片在协作流
利用钉钉现有的数据中台和算法团队的协同关系模型对用户提供个人视角下的高价值事件处理卡片。
呼应协作业务的目标,提升点击率,也就是验证协作对用户越来越有用。
协作前端算法推荐序链路设计
推荐序列表由两段组成,待处理和其他,在有了上面讲过的卡片模型设计实现后,本质上对前端来说这里的工作量只是对推荐序列表的拼接,UI层和逻辑层所有交互均已被覆盖。
待处理(重要内容):对用户强相关的处理卡片,比如未完成的待办、三条后开始折叠。
其他(可能感兴趣的):基于算法协同关系模型以及前端提供的行为埋点数据由算法中心统一产出,交互上跟随推荐序列表下拉加载。
为了填补协作在桌面端的体验空缺。依赖多端协同的应用,如文档,需要协作支持桌面端后才有可能将应用的协同消息从应用内迁移到协作。
协作PC端
本篇重点讲述协作移动端建设过程,PC端的前端设计方案实现这里先不做陈述。
应用场景举例:
APN推送钉钉年度报告承接
在接到需求的第一时间建设了通用的APN推送链路方案:利用channel建立客户端到前端的通信通道,在客户端对钉钉统一跳转协议的监听下推送给前端跳转的来源信息,同时前端通过订阅的监听事件做相关的业务逻辑操作。
import { getChannel } from '@ali/dingtalk-jsapi/plugin/uniEvent';
export function ddSubscribe(
channelName: string,
eventName: string,
handler: (data: any) => void,
useCache = true,
) {
try {
const channel = getChannel(channelName);
channel.subscribe(eventName, handler, { useCache });
} catch (e) {
logApiError('ddSubscribe fail',safeJson(e));
}
}
// 客户端开启订阅统一跳转协议监听事件,并建立两端通道
ddSubscribe('channel.jumpAction.switchtab', 'cooperate_cooperate', (data) => {
if(data?.data.from==='nianzong'){
sendUT('Page_Work_New_Year_Summary');
openLink$({url:yearSummaryUrl||'https://page.dingtalk.com/wow/dingtalk/default/dingtalk/yearsummary?dd_nav_translucent=true&wtid=yearsummary__xiezuo'})
}
});
应用区为什么要折叠
受全局切换组织、应用快捷入口吸顶、信息流工具栏吸顶等多个原因影响,协作信息流自身的内容展示区域,不足整体页面高度的1/2,新版的信息卡片,增加信息量的同时也增加了卡片高度,所以要用折叠方式将屏效比提高。
应用区展示逻辑:纯前端实现,此处无服务端交互
联动设置页的拖拽自定义顺序展示。
每个入口均由一个灰度key统一控制在协作中的展示,同时针对专属钉大客户定制降噪对应用入口进行过滤。
展示前通过客户端JSAPI进行红点数量设置。
//灰度key获取到联动降噪红点设置统一管理
export const getGrayValueFromCacheByKeys = async (keys: KeysType[] = []) => {
const result: any[] = [];
const promiseAllArr: Array> = [];
const promiseAllArrKeys: KeysType[] = [];
keys.forEach((key) => {
if (cacheGrays[key]) {
result.push(cacheGrays[key]);
return;
}
const defaultGrayKeysConfig = allGrayKeys[key];
if (!defaultGrayKeysConfig) {
result.push(true);
return;
}
if (!defaultGrayKeysConfig) {
result.push(!!defaultGrayKeysConfig);
return;
}
promiseAllArrKeys.push(key);
promiseAllArr.push(grayLemonFnFactory(...defaultGrayKeysConfig)());
});
const promiseResult = await Promise.all(promiseAllArr);
promiseAllArrKeys.forEach((key: KeysType, i: number) => {
cacheGrays[key] = promiseResult[i];
});
return cacheGrays;
};
以上是产品能力建设过程中的前端主要实现及策略,还有很多细节功能比如卡片自动定位到当前时间最近的事件卡片项、同步推送协议以及列表无感刷新等功能这里就不再赘述。
性能体验是前端绕不开的话题,我坚持认为这件事不是等项目做完再做优化,而是在业务迭代过程中将它落地。
就协作小程序的完整启动链路图如下,我们在哪些关键节点能做哪些事呢?
协作小程序启动链路图
白屏时间太长:
首屏出来前长时间白屏,用户体感较差。
大量原生API调用:
通过IDE AppLog面板,发现存在大量的原生JSAPI调用,其实很多是非首屏依赖的,而且JSAPI调用的性能损耗在不同机型下表现不一,尤其对低端机影响较大。
包体积过大:
包体积过大,一方面消耗JS初始化执行时间,另一方面还可能会存在不必要的原生API调用,更加拖累首屏性能。
以下内容全程高能,以后前端项目就这么做。
协作性能建设大图
案例1:首屏渲染体验优化-优化策略包和效果
优化静态资源:
类似常规的web玩法,主要就是图片懒加载、压缩质量,属于投入产出比非常高的手段。
降低包体积:
降低包体积不但可以减少js上下文的初始化耗时,还能减少冗余的API调用。
渲染原理:
大量的setData是拖累性能的主要原因之一,理想情况应该把从小程序启动到首屏渲染完毕之间的setData控制在一次。要做到这一点会有一些挑战,减少不合理的模块re-render,减少setData的数据内容,比如协作单张卡片操作后局部更新,而不是更新整个列表。
考虑动态插件:
对于非首屏的、功能独立&复杂、又无法拆到分包的模块,可以考虑将逻辑拆到小程序插件里,按需加载。
案例2:首屏渲染体验优化 - 钉钉客户端lwp预取接入
谋定而后动
协作没有定位服务等的前置依赖,所以可以前置请求,特别适合接入lwp预取。
绝大部分小程序的启动流程,都会经历如下环节:
整体过程是串行的,其中请求首页数据往往和用户的网络环境,服务端的性能有较大关系。串行的流程并行化是一个很常见的性能优化思路,请求首页数据这一步,往往是传一些参数给服务端,获取到数据用来渲染 UI。我们可以抽象出一些规则,在业务的JS开始执行前,并行加载首页数据。
根据历史线上数据,小程序的容器启动到业务JS开始执行,一般需要1s左右,而钉钉的LWP请求,平均执行时间在300ms左右。这意味着在容器启动阶段,就可以执行3次LWP请求,这段时间是被白白浪费了。
钉钉lwp预取 客户端、服务端、前端全链路方案
业务接入
通过getBackgroundFetchData开启客户端通信通道按需读取数据:
dd.getBackgroundFetchData({
type: "lwp",
wait: true,
success(res) {
console.log(res.fetchedData); // 缓存数据
console.log(res.timeStamp); // 客户端拿到缓存数据的时间戳
console.log(res.path); // 页面路径
console.log(res.query); // query 参数
console.log(res.scene); // 场景值
}
});
//调用时可以传入 wait 参数,表示是否等待预加载结果。如果为 true,会等待预加载任务完成后收到回调。
结果数据:
可以看到iOS设备由于本身性能较好,减少的时间有限,但是在Android上的提升效果很明显。
钉钉App杀进程的冷启动,协作整个小程序被容器劫持,也就是常驻内存的保活。接入预取后,除了小程序容器UC内核启动的时间外,基本上做到了首屏直出。
协作作为一个大流量入口据数据显示每天有3k+的弱网用户访问量,接入离线方案是改善用户体验的必取之路。
缓存策略:首屏数据直出,后续迭代,将缓存获取时机从首页onload提前到App onLaunch,能够减少数十到上百毫秒的间隔,是比较常见的手段,但是对降低业务耗时最为直接有效,另一方面从体感上来说确实会更快。
钉钉lwp-cache客户端缓存方案链路流程图
框架容器层面也有小程序快照方案-协作设置页接入,不过也并非所有场景都适用先展示缓存。
离线策略:
利用客户端lwp cache能力配合LocalStroage进行上一次请求的缓存数据的离线获取。
在前端页面将依赖网络请求的资源图片和功能进行降级。
优化前后对比
关于稳定性,协作前端还做了全功能的降级体验,如服务端接口异常的重试页、数据为空的降级展示、jsapi失败后的重试机制等全方位保障系统的高可用性和稳定性。
做业务要为最终结果负责,而前端角色从技术/产品思维到业务思维的一跃有很多天然的瓶颈与鸿沟,技术人应当发挥对业务前瞻性的理解,好的架构设计背后一定是对于业务的高度认知与抽象,过程中要对业务关键指标有正确的理解,而不是简单纯功能的堆砌。
过度的产品化执念导致容易陷入细节;
缺少与业务方长时间高频度的互动,对商业模式的理解、数据的敏感度不足;
从产品/技术思维到业务思维的转变,可以尝试从以下几个方面来培养:
1)培养对目标与数字的敏感度,尝试收集并形成自己的订阅报表,定期 Review,多追问指标升降背后的原因。
2)加强与业务方互动,多从业务目标视角看待每个需求,使用STAR法则梳理关联关系,多问几个为什么。
3)尝试结合掌握的信息去做公式拆解与沙盘推演,例如App DAU = (MAU * 月均访问频次)/30 +日均拉新,目前的现状每个指标分别在什么量级,每个需求又分别服务于哪个指标,能够提升多少,提升后是否能推导出目标达成,拆解事项并梳理优先级。
4)抛下过于超前的产品执念,避免陷入细节,以产出最小可行产品(MVP)为原则快速试错与迭代,区分好「锦上添花」与「雪中送碳」。
用户越来越深入地使用钉钉,产生了越来越多的协同“事件”。
「协作」通过高效的推荐和筛选机制,帮助用户更好地管理、更轻松地处理每日纷繁的“事件”。
「协作」有机会成为用户处理“事件”的第一入口,让用户更轻松地完成工作。目前正在积累基础能力,已经有二十多个协同场景接入「协作」,每日用户通过协作处理的任务数接近 200 万个。
长期价值是,入口更便捷、场景更丰富、推荐更精准、操作更简单。