实时 K 线与行情性能:推送怎样变成一根蜡烛,以及怎样不打断用户
项目 trade-app
1. 背景与问题
K 线页是交易 App 里推送最密、交互最多的页面:
- 行情服务器持续推送当前周期的 K 线、分时、快照、盘口、逐笔;
- 用户会左右拖动看历史、捏合缩放、长按看十字光标;
- 主图叠加均线、布林带等指标,副图是 MACD、KDJ、RSI 等。
示意数据:一根 K 线由开盘、最高、最低、收盘四个价构成,收高于开为阳线,反之为阴线。配色按红涨绿跌。
示意数据:主图是蜡烛与 MA5 / MA10 / MA20,副图是成交量。数据为随机生成。
两类需求天然冲突:推送要尽快画上去,而用户在拖动看历史时,画面不能跳。行情列表也有类似的问题:列表行数很多,全量订阅既浪费带宽,也让主线程忙于刷新看不见的行。
第 2 到 4 节沿着「推送 → 模型 → 图层」讲 K 线页怎样既跟上推送又不打断用户;第 4.7 节是 K 线页上的交易登录接入;第 5 节是行情列表的订阅。
2. 整体结构
3. 数据模型
一根 K 线的模型上,除了开、高、低、收、成交量、成交额和时间戳,还挂着两类「算好的东西」:
- 绘制用的点位和颜色:画的时候不用再从价格换算坐标;
- 各个指标的值:均线、EMA、布林带、MACD、KDJ、RSI 等直接是模型的字段。
另外有一个「是否新的一根」标记,决定这条推送是追加还是替换。
4. 关键实现
4.1 解码不在主线程
- 行情连接的回调本身就在一条专用队列上。K 线页收到推送后,在这条队列上把负载解成 Protobuf 消息,再切到主线程处理。
- 历史 K 线一次几百根,转换成模型的工作放在一条专用的转换队列上,转完再回主线程交给视图。
4.2 推送怎样变成一根蜡烛
// 示意,非原始代码(分时图)
func apply(push: KLine, to models: [KLine], minutes: [TimeInterval]) -> [KLine] {
guard let last = models.last else { return [] }
if let end = minutes.last, push.timestamp > end { return [] } // 超出当日时间表:忽略,留了待办
if push.timestamp == last.timestamp { push.isNew = false; return [push] } // 同一分钟:替换
if push.timestamp - last.timestamp == 60 { push.isNew = true; return [push] } // 下一分钟:追加
guard let i = minutes.firstIndex(of: last.timestamp),
let j = minutes.firstIndex(of: push.timestamp) else { return [] } // 调试包里直接断言
if j - i == 1 { push.isNew = true; return [push] } // 跨交易时段(如午休):追加
return emptyMinutes(between: i, and: j, from: last) + [push] // 中间缺了:先补空分钟
}
分时图的判断依赖当日的分钟时间表(交易日信息里每个交易分钟的时间戳):
| 情况 | 处理 |
|---|---|
| 时间戳与最后一根相同 | 替换最后一根 |
| 正好相隔 60 秒 | 追加一根 |
| 超过时间表的最后一分钟 | 忽略;代码里留了待办:进入新周期时应通知外部重新拉取 |
| 在时间表里相邻,但相隔不止 60 秒 | 跨了交易时段(例如午休),直接追加 |
| 在时间表里隔了两格以上 | 中间有推送缺失,按时间表补齐空分钟,再追加 |
| 时间表里找不到 | 忽略这次推送;调试包里直接断言,尽早暴露数据异常 |
示意数据:港股的上午、下午两个时段之间有午休。按自然时间画会空出一段;按交易分钟时间表画,两个时段首尾相接,跨午休的推送在时间表里是相邻的两格,直接追加。
示意数据:中间缺了几分钟的推送时,按时间表先补出空分钟,再追加新的一根。
普通周期的 K 线简单一些:时间戳和最后一根相同就替换,否则追加。
示意数据:左边是推送前;中间是同一时间戳的推送替换了最后一根(虚线框是旧值);右边是新时间戳的推送追加了一根。
4.3 只在「停在最右侧」时重绘
用户把图拖到中间看历史时,如果每来一条推送就重绘并滚到最右,画面就会跳走。所以主图在收到一根新的或更新的 K 线时:
不在最右侧时数据照样更新,只是不画;等用户拖回来,滚动回调里的重绘会把最新数据画上去。
4.4 只画可见区
每种图元是一个 CAShapeLayer 子类:蜡烛、分时线、均线各一个,路径用 UIBezierPath 拼。重绘的步骤是:
示意数据:蓝色区域是屏幕可见区,纵轴范围只按这一段的最高、最低来定;灰色部分有数据,但不建图层。
只画可见区,所以历史数据再多,单次绘制的量只和屏幕宽度、蜡烛宽度有关。代价是每次滚动回调都会移除并重建可见区的全部图层。
左滑到最左端时,用「加载更多」的消息 id 向行情服务器请求更早的历史,应答回来按 id 认出,插到数组前面。
4.5 滚动期间先缓存推送
拖动和减速期间,K 线和分时推送先进缓存;滚动停下后统一回放。
「是否需要缓存」等于「正在滚动」且「图已经画完」。滚动停下时(手松开时已静止,或惯性减速结束)把「正在滚动」置否,再处理缓存。
10 秒兜底是为了防止「滚动结束」的回调没有到来:减速结束的回调只在拖拽引起的惯性滚动时才有,代码设置偏移量不会触发它;一旦没等到,缓存就会一直攒下去。每次开始拖拽都生成一个新的标识,10 秒后只有标识还是这一次、而且仍在滚动,才强制按结束处理,避免旧的兜底误伤新的滚动。
示意数据:蓝色区间是拖拽和惯性减速;这期间的推送(空心点)只进缓存不重绘,停下后回放缓存,再重绘一次。
4.6 指标增量计算
指标底层用 TA-Lib。首次加载算全量;推送来时用「只算最后一根」的模式:
| 位置 | 指标 |
|---|---|
| 主图 | MA、EMA、BOLL、SAR 等 |
| 副图 | MACD、RSI、KDJ、DMI、ATR 等 |
「只算最后一根」只影响结果写回哪里;传给 TA-Lib 的仍是完整的收盘价数组。TA-Lib 本身需要回看窗口,所以不能只传一个点,但也不必传全部,见第 7 节。
示意数据:副图 MACD(DIF、DEA 与柱)和 KDJ(K、D、J)。推送到来时只为最后一根写回新值,输入仍是完整的收盘价序列。
捏合缩放用一个自定义的手势识别器,蜡烛宽度夹在最小、最大值之间,以捏合中心为锚点;分时图不允许捏合。长按显示十字光标,单击切换显隐。
4.7 K 线页上的交易登录
K 线页底部可以直接下单,下单前要有交易会话。这部分接入的是交易登录管理器:
- K 线页新增交易登录逻辑;
- 改为调用统一的交易登录入口,不再直接用登录弹窗;
- K 线页与证券、期货下单页改为继承带网络与交易连接状态提示的页面基类;
- 证券强制改密时,离开改密页后再次弹出强制改密框;
- 图表页输错交易密码时只保留一处失败提示。
5. 行情列表:只订阅看得见的行
行情列表上的几种手段:
| 手段 | 做法 |
|---|---|
| 可见行订阅差分 | 只订阅屏幕内的代码;滚动后退订离开屏幕的、订阅新进入的 |
| 实时与延时分组 | 按是否有实时权限把代码分成两组,分两次订阅 |
| 「是否实时」页面级缓存 | 在页面里算一次,不再每个代码都去读服务端配置 |
| 去掉懒加载里的数据库查询 | 列表模型懒加载时不再每个都查一次合约表,改为按市场类型直接判断 |
| 价格聚合 | 快照、盘口、基础信息合并成一个价格模型回调,页面只关心一个回调 |
其中第四条是一次专门的市场页性能修复:原来每个列表模型在懒加载某个属性时都会查一次本地数据库里的合约表,一屏几十行就是几十次查询。另外「是否实时行情」原来每个代码都要读一次服务端配置,而这个读取在配置为空时还会顺带触发一次账户信息请求。
6. 对比与取舍
| 问题 | 另一种做法 | 采用的做法 | 代价 |
|---|---|---|---|
| 推送与用户拖动冲突 | 每条推送都重绘并滚到最右 | 只在最右侧且没在滚动时重绘;滚动期间缓存 | 需要 10 秒兜底防回调丢失 |
| 绘制量 | 一次画完全部历史 | 只画可见区 | 每次滚动重建可见图层 |
| 坐标换算 | 绘制时现算 | 点位、颜色缓存在模型上 | 模型变重,缩放后要重算 |
| 指标 | 每次全量重算 | 只写回最后一根 | 仍传完整数组 |
| 列表订阅 | 全量订阅 | 可见行差分 | 新进入的行要等订阅生效才有推送 |
| 解码 | 主线程 | 专用队列 | 要注意回主线程再改界面 |
7. 可以改进的地方
下面每一条都是方向,不是已经做完的改动。
| 现在的做法 | 问题 | 改进方向 |
|---|---|---|
| 每次滚动都移除并重建可见图层 | 滚动时创建和销毁图层的开销 | 复用图层,只更新路径;或在单个图层里一次画完可见区 |
| 推送来一条处理一条 | 推送密集时主线程忙 | 用 CADisplayLink 把同一帧内的推送合并成一次重绘 |
| 指标增量仍传完整数组 | 数据越多越慢 | 只取「窗口长度」那么多的尾部数据做增量 |
| 滚动结束靠回调 + 10 秒兜底 | 兜底期间推送一直积压 | 用滚动视图的状态轮询或在主循环空闲时回放 |
| 只订阅可见行 | 新进入的行要等订阅生效 | 预订阅可见区上下各一屏 |