提起 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 - > 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 原生版本,买一送一哦。:-)
音乐馆 macOS 版本
什么是数据驱动
简单来说,数据驱动是一种编程思想,程序的
状态由数据确定
,通过提供的接口
操作数据
来控制
程序逻辑
,而不建议直接操作界面 UI 组件。除了 Web 技术栈外,在现时流行的客户端开发框架 Flutter、SwiftUI 上都能找到数据驱动的影子。
开发者只需要用代码或其他方式
描述各个界面元素与数据之间的关系
,数据的流向、界面的维护工作将由框架自动处理,大大简化程序员需要关注的内容。
响应式编程
很多人不明白响应式实现的原理,我曾经也是,以为 C++ 作为一门静态编译型语言,是无法在
运行期
收集到,本应是
编译期
才能获知的
依赖关系
。毕竟没有执行到的条件分支,在运行时就根本不存在。
直到读了 Vue.js 的源码后,才理解了依赖关系是如何在
运行时收集维护
的。
其核心要点就两条:
这里容易疏忽的点在于,如果代码会执行到另一分支,那必然
当前的依赖
会发生变化。因此没有必要一次就收集到完整的依赖,只需要确保收集当前代码路径的依赖即可。
如何收集依赖
很简单,当一个函数尝试
读取
一个响应式数据时,便记录该函数对此数据有依赖。响应式数据有
更新
时,遍历其所有依赖函数,重新执行,然后
再次收集
新依赖。
由于 C++ 是编译型语言,很难像 Vue 那样进行数据的动态 hook/proxy,Klee 直接提供了
响应式数据封装
,开发阶段就替换普通数据类型使用。
响应式数据
在 Klee 框架中使用类型
Reactive
表示,允许被依赖,仅暴露读取接口,内部采用多态实现。
// 通过常量创建一个只读的响应式数据 Reactive<int > score = readonly(60 );std ::cout
响应式变量
在 Klee 框架中使用类型
Value
表示,支持读写内部数据。
Value
可以隐式转换为
Reactive
使用,此时写接口被隐藏,但依赖方仍能观察到数据的变化。
Value<std ::string > name; name = "tibberswang" ; // 设置值 std ::cout // 也可以使用这种方式,创建一个指定初始值的响应式变量 // 注:reactive 方法对 const char* 有特殊重载,产出的类型是 std::string auto /* Value<:string> */ name = reactive("tibberswang" );
计算数据
通过
computed
方法产生,返回类型对外依然是
Reactive
,其内容通过一个 lambda (C++) 或者 block (Objective-C) 计算得出,计算结果会被
缓存
。在计算数据的函数体内使用到响应式数据,会
自动建立依赖关系
,若某个依赖项发生变化,计算属性将被标记为 dirty,并在
下次被使用
或者
下一个消息循环
触发重新计算。
auto /* Reactive */ namelength = computed([=](){ return name->size(); // namelength 会自动依赖 name });std ::cout std ::cout name = "tibbers" ; // 改变计算数据所依赖的 name std ::cout
使用
响应式变量
和
计算数据
,就可以组合搭建出各种业务逻辑了。
现实场景中,计算也许不能同步完成,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 = GetUser(user_id); Reactive corp = GetUserCorp(user); Reactive config = GetCorpConfig(); Reactive 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<: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
扫码加入企业微信