Klee:用 C++ 实现数据驱动开发
提起 C++ 这门已有 38 年历史的语言,大家或多或少都会有一定的了解,“ 面向对象 ”、“ 过程式编程 ”这些词汇立刻在脑海中浮现出来。“ 高性能 ”、“ 高复杂性 ”这两大标签,也伴随着 C++ 多年来一直在众多语言中独树一帜。
而我们在实际项目的开发过程中发现,同一个功能,综合考虑 前期开发 、 后期 bug 与 UI 还原 等阶段的人力投入,使用 Web 技术栈 来实现前端页面,研发效率大约是 平台原生开发 的 2 到 3 倍 。这其中开发效率的差异,让我们好奇去深入探究其中的原因。
近年来崛起的前端三大框架 Angular、React、Vue,支持 组件化 和 响应式 开发,为前端带来了丰富的生态系统,极大地简化了 Web 开发的过程,使得开发大型 Web 应用变得轻松。而反观 C++ 近年的进步,极少有 开发流程和理念 方面的改进,所谓的 Modern C++,在许多人眼里仅仅是增加了许多晦涩难懂的内容,又进一步提升了开发门槛,对其兴趣寥寥。
你可能也接触并了解过前端的 组件化 和 响应式 开发,但是否想过某一天,也能够 在 C++ 实现 ?
概览
给出以下设计稿,试着大致评估下,多少时间可以搞定?
先别急着看答案,来分析一下这个典型的列表界面:
-
控件方面:需要使用 TableView 方式布局,每行均有头像、名字、状态圆点、作品列表和下载按钮。头像使用 URL 异步下载 ,需考虑潜在的 cell 复用问题。状态圆点的颜色、下载按钮的文案及禁用态应当随着下载任务的状态 实时更新 。 -
布局方面:需要 适配不同尺寸 的屏幕,头像和按钮分居左右, 剩余空间 留给名字和作品列表。 -
功能方面:点击按钮会使得 下载状态发生流转 ,执行下载操作并 更新 圆点及下载按钮,并在下载完成/失败后再次触发 更新 。
心里有数了么,下面答案揭晓:
PageRef MusicLibrary::ListPage(Reactive<std::vector<Item>> items) { return Page("音乐馆", List(items).cell([](const Item& item)->CellRef{ auto actionText = computed([=]{ switch (item.state) { case Downloading: return "下载中"; case Downloaded: return "已下载"; default: return "下载"; } }); auto stateColor = computed([=]{ switch (item.state) { case Downloading: return 0x4378be_rgb; case Downloaded: return 0x31c27c_rgb; default: return 0xaaaaaa_rgb; } }); return Cell( Row().width(FillParent).padding(12).align(Middle).child( Image(item.avatar).size(48), Space(12), Column().flex(1).child( Label(item.name, 15_pt_B), Space(4), Row().align(Middle).interspacing(4).child( Shape(stateColor).size(6).radius(3), For(item.works, [](const std::string& work)->WidgetRef{ return Label(work, 12_pt, 0x666666_rgb); }) ) ), Space(12), Button(actionText, 14_pt).primary().enabled([=]{ return *item.state == Undownloaded; }).onTap([=]{ item.state = Downloading; PerformDownload(item, [=](bool success){ item.state = success ? Downloaded : Undownloaded; }); }) ) ).sepIndent(72); }) );}
这应该不是你熟悉的代码风格,不过如果你使用过响应式开发框架,应该也不算太陌生。仅仅用数十行代码就完成了这样一个界面的开发,并且具备 实时更新 的能力,它不香吗?
代码如此简洁,都是数据驱动的功劳。框架能够智能的跟踪并建立数据和界面的关系,在 数据变化 的时候更新界面,无需开发者手动去管理。
先消化一下,再看看接下来的 小惊喜 吧。一行代码都不用改,附赠同款 macOS 原生版本,买一送一哦。:-)
什么是数据驱动
简单来说,数据驱动是一种编程思想,程序的 状态由数据确定 ,通过提供的接口 操作数据 来控制 程序逻辑 ,而不建议直接操作界面 UI 组件。除了 Web 技术栈外,在现时流行的客户端开发框架 Flutter、SwiftUI 上都能找到数据驱动的影子。
开发者只需要用代码或其他方式 描述各个界面元素与数据之间的关系 ,数据的流向、界面的维护工作将由框架自动处理,大大简化程序员需要关注的内容。
响应式编程
很多人不明白响应式实现的原理,我曾经也是,以为 C++ 作为一门静态编译型语言,是无法在 运行期 收集到,本应是 编译期 才能获知的 依赖关系 。毕竟没有执行到的条件分支,在运行时就根本不存在。
直到读了 Vue.js 的源码后,才理解了依赖关系是如何在 运行时收集维护 的。
其核心要点就两条:
-
初始化即执行一次,收集 初始 依赖 -
每次执行时,都 重新收集 依赖关系
这里容易疏忽的点在于,如果代码会执行到另一分支,那必然 当前的依赖 会发生变化。因此没有必要一次就收集到完整的依赖,只需要确保收集当前代码路径的依赖即可。
如何收集依赖
很简单,当一个函数尝试 读取 一个响应式数据时,便记录该函数对此数据有依赖。响应式数据有 更新 时,遍历其所有依赖函数,重新执行,然后 再次收集 新依赖。
由于 C++ 是编译型语言,很难像 Vue 那样进行数据的动态 hook/proxy,Klee 直接提供了 响应式数据封装 ,开发阶段就替换普通数据类型使用。
响应式数据
在 Klee 框架中使用类型
Reactive<T>
表示,允许被依赖,仅暴露读取接口,内部采用多态实现。
// 通过常量创建一个只读的响应式数据Reactive<int> score = readonly(60);std::cout << *score; // 输出 60
响应式变量
在 Klee 框架中使用类型
Value<T>
表示,支持读写内部数据。
Value<T>
可以隐式转换为
Reactive<T>
使用,此时写接口被隐藏,但依赖方仍能观察到数据的变化。
Value<std::string> name;name = "tibberswang"; // 设置值std::cout << *name; // 读取值:tibberswang// 也可以使用这种方式,创建一个指定初始值的响应式变量// 注:reactive 方法对 const char* 有特殊重载,产出的类型是 std::stringauto /* Value<std::string> */ name = reactive("tibberswang");
计算数据
通过
computed
方法产生,返回类型对外依然是
Reactive<T>
,其内容通过一个 lambda (C++) 或者 block (Objective-C) 计算得出,计算结果会被
缓存
。在计算数据的函数体内使用到响应式数据,会
自动建立依赖关系
,若某个依赖项发生变化,计算属性将被标记为 dirty,并在
下次被使用
或者
下一个消息循环
触发重新计算。
auto /* Reactive<size_t> */ namelength = computed([=](){ return name->size(); // namelength 会自动依赖 name});std::cout << *namelength; // 读取计算数据的值:11std::cout << *namelength; // 再次读取:不会重复执行计算name = "tibbers"; // 改变计算数据所依赖的 namestd::cout << *namelength; // 重新计算得到新值:7
使用 响应式变量 和 计算数据 ,就可以组合搭建出各种业务逻辑了。
现实场景中,计算也许不能同步完成,Klee 还引入了
异步计算数据
。异步计算数据提供
available()
和
state()
方法,可以获取到异步计算数据的响应式状态,辅助编写逻辑。
auto asyncData = computed([=](Resolver<std::string> r){ // 进入函数体后 state 的状态被置为 Computing // 假设项目里的异步下载工具方法是 DownloadURL DownloadURL(url, [=](int errcode, const std::string& data){ if (!errcode) { // resolve 方法为计算属性设置值 // 并且设置 available = true、state = Computed r.resolve(d); } else { // reject 方法反馈计算失败,并可设置错误码或错误描述 // 并且设置 available = false、state = Error r.reject(errcode); } }];});auto title = computed([=]{ return asyncData.available() ? "已下载" : "未下载";});
一个实际例子
以下是企业微信移动端的一个实际场景。
留意消息 发送者的名称 显示,看似简单,里边有多少门道?
-
消息里只有 UserID,用户信息可能需要通过 UserID 异步拉取 -
特殊 UserID 需要展示本地化名字,语言 跟随系统设置 -
CorpID 在用户信息里,拿到 CorpID 后企业信息可能需要 异步拉取 -
名字显示规则(中文、英文、实名等)在企业配置里,企业配置可能需要 异步拉取 -
群昵称优先展示,群信息可能需要 异步拉取 -
若给此人设置了备注,备注优先展示
上面只是显示规则的一部分。该名称要求 即时更新 ,意味着该控件需要注册这些通知:
-
用户信息观察者 -
企业信息观察者 -
企业配置观察者 -
群信息观察者 -
语言变更广播
把展示规则整理好,写出正确的代码并不算困难,做好 异步逻辑 和 更新维护 才是麻烦。这恰好是数据驱动最大的优势。若能以 响应式数据 的形式提供这些信息,那么就 不再需要手工维护 异步逻辑和通知,只需按照显示规则来写代码,剩下的数据驱动框架 全部搞定 。
Reactive<std::string> GetDisplayName(user_id, conv_id) { Reactive<User> user = GetUser(user_id); Reactive<CorpInfo> corp = GetUserCorp(user); Reactive<CorpConfig> config = GetCorpConfig(); Reactive<Conv> conv = GetConv(conv_id); Reactive<std::string> lang = GetLanguage(); return computed([=]{ if (IsLocalizableUser(user_id)) { return i18n(lang, LocalizableUserName); } if (user.available()) { std::string userName; if (HasRemark(*user)) { userName = GetRemark(*user); } else if (conv.available() && HasNickname(conv, user)) { userName = GetNickName(conv, user); } else if (config.available() && config->prefersChineseName) { userName = user->chineseName(); } else { userName = user->englishName(); } std::string corpName; if (corp.available()) { if (IsMyCorp(corp)) { corpName = EmptyCorpName; } else { corpName = corp->corpName(); } } else { corpName = i18n(lang, UnknownCorpName); } return CombineName(userName, corpName); } return i18n(lang, UnknownSenderName); });}
可以看到,代码非常清晰简洁,且具备 缓存 、 懒加载 、 防抖去重 、 请求聚合 等优化策略,往往能比手写代码提供 更优的性能 。
组件化开发
看完前一个例子,你是否觉得缺了点什么?对,上面的函数最终仅返回了一个
Reactive<std::string>
,用在哪里呢?
接下来就是本节要说的 组件化开发 了。当然,如果只想使用响应式编程来进行开发也是可以的:
UILabel *label = [UILabel new];label.font = [UIFont systemFontOfSize:14];label.textColor = [UIColor redColor];[label kl_bindText:GetDisplayName(user_id, corp_id)];
为
UILabel
提供的分类方法
kl_bindText:
的作用就是
数据绑定
啦。调用
kl_bindText:
后,若该响应式数据发生变化,框架会在
下次绘制之前
重新对响应式数据求值,然后调用
setText:
方法改变
label
的文本,且触发视图树的
重新布局
。
这样仍然有四行代码,还是有些繁琐了。Klee 提供了 声明式 的开发模式,我们可以这样编写代码:
Label(14_pt, 0xFF0000_rgb, GetDisplayName(user_id, corp_id));
注:上述 _pt、_rgb 后缀是利用 C++ 的 User-defined literals 特性实现的自定义字面量。
Label 是 Klee 框架内置提供的文本显示组件,构造时的参数支持同时传入字符串、属性字符串、字体、颜色,且参数允许 任意增减或调换顺序 ,例如这样也是 OK 的:
Label(GetDisplayName(user_id, corp_id), 17_pt);
这些参数都支持响应式,如果需要支持 动态修改颜色 ,那么参数传入一个表示颜色的响应式数据即可:
auto vipcolor = computed([=]{ // 指定的 user_id 显示为红色 if (user_id == 10001) { return 0xFF0000_rgb; // 红色 } else { return 0x0_rgb; // 黑色 }});Label(GetDisplayName(user_id, corp_id),vipcolor);
作为性能优化的一环,若 Label 仅有颜色发生变化,框架认为无需重新计算 Label 的尺寸, 不会触发 视图树重新布局。
继续上个例子
使用 组件化开发 的方式完成整个 cell 的编写。
WidgetRef MessageCell(msg) { auto senderName = GetDisplayName(msg.senderId, msg.convId); return Row().padding(16).child( Avatar(msg.senderId).size(40, 40), Space(8), Column().child( Label(14_pt, 0x0_rgb, senderName).padding(0, 8, 0, 0), Bubble(msg).padding(4, 0, 0, 0) ) );}
你品,你细品。短短数行代码,利用各种 基础组件的组合 ,即可完成各种复杂界面功能的配置和布局。没有继承,没有方法覆盖,也没有监听和观察者。基于 FlexBox 的布局模型能自行 适配各类屏幕宽度 。
混合开发模式
为了能够 无痛渐进式 的将 Klee 接入项目中,Klee 可以和现有的 Native 开发模式 任意搭配 使用,并不需要项目进行全面改造。
Klee 提供的视图组件允许隐式转换为原生视图,直接参与到原有 Native 模式的开发。
UILabel * label = Label(name, 17_pt); // iOSNSTextField *label = Label(name, 17_pt); // macOS
包含布局组件的
WidgetRef
对象可以隐式转换为
KLWidgetView
或者
KLWidgetScrollView
来参与原有 Native 模式开发。
// KLWidgetView 与 KLWidgetScrollView 的区别是能否支持滚动KLWidgetView *view = MessageCell(msg);[self.view addSubview:view];
项目已有的基于
UIView
或
NSView
的控件亦可以直接加入 Klee 套餐,还记得上面用到的 Avatar 组件么?把原生视图对象使用 View 组件包装一次,就可以接受 Klee 框架的布局管理。
// 假设项目里已经有 @interface MyAvatarView : UIViewMyAvatarView *avatarView = [[MyAvatarView alloc] initWithUserId:msg.senderId];Component avatar = View(avatarView).size(40, 40);
也可以写个极简单的封装函数,MyAvatarView 立刻摇身一变成了 Klee 风格的 Avatar 组件。
Component Avatar(userid) { return View([[MyAvatarView alloc] initWithUserId:userid]);}
组件生态系统
Klee 目前提供了三类基础组件:
布局组件
管理子组件的位置和大小,不参与绘制,不会出现在最终视图树中。例如
Stack
、
Row
、
Column
等。
视图组件
运行时会产生一个对应的原生视图,完成实际的绘制和交互。例如
Label
、
Image
、
Button
、
CheckBox
等,使用
View
可以封装任意原生视图。
Shape
组件用于产生各种视觉图形元素。
List
组件封装了最常用的
TableView
,可以快速搭建一个支持视图复用的列表界面。另外还有
Page
,对标 iOS 的
UIViewController
或 Android 的
Activity
设计。
逻辑组件
提供了基本的结构化能力,通过
If/Then/Else
和
For
基础组件,可以实现简单的条件和循环。
三类组件可以进一步 组合嵌套 ,形成复合组件。
与 RxSwift 的对比
同为 Native 数据驱动开发框架,Klee 从设计思路上与主流的 RxSwift 等框架有所不同。这里先忽略 C++ 和 Swift 本身语言的能力差异,仅对框架设计本身进行一些对比分析。
数据源
Klee 的推荐开发实践是定义独立的 Model、ViewModel 结构来存放响应式数据,再绑定至 UI 控件,这样更方便跨平台开发复用代码。
RxSwift 通常以 UI 控件作为数据源,控件直接产生监听序列,代码更加简洁,但要做到跨平台,代码改动较多。
流程可控性
通过 Klee 开发出的代码,是多个接收输入、产出输出的片段,开发者不会严格描述逻辑关系,只要每个片段的输入满足,流程就会并行执行。
RxSwift 有比较清晰的数据流向,需要通过代码描述过程间的依赖关系,但也意味着开发者需要自己梳理流程,才能保证逻辑正确且达到最佳性能。
多输入源
由于 Klee 的依赖关系是由框架自动建立的,不需要开发者维护,在多输入源的情况代码仍然非常简洁。
RxSwift 单输入源代码简洁清晰,但多输入源的场景需要开发者使用各种操作符来连接生成新的序列,学习门槛稍高。
生命周期管理
Klee 是控件订阅数据,因此监听者的生命周期自然跟随控件,一起销毁;且引用的响应式数据全部来自 Model,不存在循环引用问题。
RxSwift 是数据绑定控件,因此需要开发者手动指定 disposeBag 来控制监听者的生命周期,且回调函数里一个错误的 self 捕获就可能导致灾难性的后果。
未来展望
代码开源
Klee 现阶段在腾讯 内部开源 ,应用在企业微信 iOS/Android/macOS 三端的部分功能中。实践表明,开发同一个功能,代码量大约只有传统开发方式的 60%,且具备更好的可读性和可复用性。
待框架经过更大规模的实际检验,同时 API 保持稳定后,再进行对外开源。
跨平台能力
Klee 响应式内核完全使用 C++ 编写,目前在 iOS、macOS、Android 已经实现跨平台,Windows 平台额外做一些修改亦可编译使用。Android 上层若需接入还要提供一套 Java 层接口封装。
组件化部分目前仅提供了 iOS 和 macOS 的实现,已经能做到一份代码兼容两个平台运行。只要为各平台都提供一套基本组件的 Native 实现,这个开发模式便可以进一步扩展到 Android 和 Windows,实现 大部分代码跨平台复用 。
可视化界面搭建
组件化开发,非常适合通过 所见即所得 的方式来搭建。这个能力不仅仅能让开发同事从中受益,产品、设计同事也能够自行制作,快速体验界面效果,甚至可以直接交付代码,代替原型稿和设计稿。
团队介绍
企业微信客户端团队,包括 iOS、Android、Windows、Mac、Web 五大平台。我们重视跨平台技术框架的研发,各类原创技术专利,截止去年,仅数十人的技术团队在近3年内提交技术专利百余项。团队招聘优秀技术人才,岗位分布在成都、广州、深圳。欢迎在官网投递简历。
可在 hr.tencent.com 搜索企业微信相关岗位,或者扫码联系 HR