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::string
auto /* 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; // 读取计算数据的值:11
std::cout << *namelength; // 再次读取:不会重复执行计算
name = "tibbers"; // 改变计算数据所依赖的 name
std::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); // iOS
NSTextField *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 : UIView
MyAvatarView *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