实时 K 线与行情性能:推送怎样变成一根蜡烛,以及怎样不打断用户

项目 trade-app

  • K 线
  • 行情
  • 绘制
  • CAShapeLayer
  • 性能
  • 订阅

1. 背景与问题

K 线页是交易 App 里推送最密、交互最多的页面:

  • 行情服务器持续推送当前周期的 K 线、分时、快照、盘口、逐笔;
  • 用户会左右拖动看历史、捏合缩放、长按看十字光标;
  • 主图叠加均线、布林带等指标,副图是 MACD、KDJ、RSI 等。

一根 K 线的开、高、低、收

示意数据:一根 K 线由开盘、最高、最低、收盘四个价构成,收高于开为阳线,反之为阴线。配色按红涨绿跌。

蜡烛图叠加均线,下方是成交量副图

示意数据:主图是蜡烛与 MA5 / MA10 / MA20,副图是成交量。数据为随机生成。

两类需求天然冲突:推送要尽快画上去,而用户在拖动看历史时,画面不能跳。行情列表也有类似的问题:列表行数很多,全量订阅既浪费带宽,也让主线程忙于刷新看不见的行。

第 2 到 4 节沿着「推送 → 模型 → 图层」讲 K 线页怎样既跟上推送又不打断用户;第 4.7 节是 K 线页上的交易登录接入;第 5 节是行情列表的订阅。


2. 整体结构

按类型解负载K 线 / 分时是否滚动结束回放
是否快照 / 盘口
行情连接 回调在专用队列K 线页控制器 当前代理推送类型正在滚动且图已画完?推送缓存判新还是更新K 线视图 追加或替换,重算最后一根 指标主图视图停在最右侧且没在滚动?重绘可见区只更新数据价格聚合工具头部报价历史 K 线应答转换队列 Protobuf → 模型
K线页控制器是否正在滚动图是否已画完是否需要缓存推送推送缓存价格聚合工具加载更多的消息 idK线视图模型数组追加或替换最后一根重算最后一根指标主图视图当前起始下标可见模型切片是否在最右侧重绘可见区()K线模型开高低收量额时间戳是否新的一根缓存的绘制点位与颜色各指标的值图元图层蜡烛分时线均线

3. 数据模型

一根 K 线的模型上,除了开、高、低、收、成交量、成交额和时间戳,还挂着两类「算好的东西」:

  • 绘制用的点位和颜色:画的时候不用再从价格换算坐标;
  • 各个指标的值:均线、EMA、布林带、MACD、KDJ、RSI 等直接是模型的字段。

另外有一个「是否新的一根」标记,决定这条推送是追加还是替换。


4. 关键实现

4.1 解码不在主线程

  • 行情连接的回调本身就在一条专用队列上。K 线页收到推送后,在这条队列上把负载解成 Protobuf 消息,再切到主线程处理。
  • 历史 K 线一次几百根,转换成模型的工作放在一条专用的转换队列上,转完再回主线程交给视图。
主线程转换队列K 线页行情连接(专用队列)主线程转换队列K 线页行情连接(专用队列)历史 K 线应答异步转换为模型数组转换完成赋值并绘制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 线时:

是
是
是否否否
是否
收到一根 K 线先算一次:是否停在最右侧新的一根?追加到模型数组在最右侧?更新内容宽度没在滚动?重绘,并把偏移量推到最右不重绘替换最后一根在最右侧且没在滚动?重绘不重绘

不在最右侧时数据照样更新,只是不画;等用户拖回来,滚动回调里的重绘会把最新数据画上去。

4.4 只画可见区

每种图元是一个 CAShapeLayer 子类:蜡烛、分时线、均线各一个,路径用 UIBezierPath 拼。重绘的步骤是:

滚动偏移量可见区第一根的下标 和它的左侧 X取出可见的模型切片求可见区的最高、最低移除旧图层,按切片新建更新是否在最右侧

只为屏幕可见区建图层,屏幕外的部分灰掉

示意数据:蓝色区域是屏幕可见区,纵轴范围只按这一段的最高、最低来定;灰色部分有数据,但不建图层。

只画可见区,所以历史数据再多,单次绘制的量只和屏幕宽度、蜡烛宽度有关。代价是每次滚动回调都会移除并重建可见区的全部图层。

左滑到最左端时,用「加载更多」的消息 id 向行情服务器请求更早的历史,应答回来按 id 认出,插到数组前面。

行情服务器行情长连接K 线页行情服务器行情长连接K 线页滑到最左端,请求更早的历史带「加载更多」的消息 id请求应答,原样带回消息 id按消息 id 认出是历史应答插到模型数组前面,重绘可见区

4.5 滚动期间先缓存推送

拖动和减速期间,K 线和分时推送先进缓存;滚动停下后统一回放。

开始拖拽(生成本次滚动的 标识)推送进入缓存拖拽结束且不再减速 / 减速 结束10 秒兜底,且标识仍是本次缓存的推送依次处理
实时滚动中回放

「是否需要缓存」等于「正在滚动」且「图已经画完」。滚动停下时(手松开时已静止,或惯性减速结束)把「正在滚动」置否,再处理缓存。

10 秒兜底是为了防止「滚动结束」的回调没有到来:减速结束的回调只在拖拽引起的惯性滚动时才有,代码设置偏移量不会触发它;一旦没等到,缓存就会一直攒下去。每次开始拖拽都生成一个新的标识,10 秒后只有标识还是这一次、而且仍在滚动,才强制按结束处理,避免旧的兜底误伤新的滚动。

滚动期间推送进缓存、停下后回放并重绘一次的时间线

示意数据:蓝色区间是拖拽和惯性减速;这期间的推送(空心点)只进缓存不重绘,停下后回放缓存,再重绘一次。

4.6 指标增量计算

指标底层用 TA-Lib。首次加载算全量;推送来时用「只算最后一根」的模式:

位置 指标
主图 MA、EMA、BOLL、SAR 等
副图 MACD、RSI、KDJ、DMI、ATR 等

「只算最后一根」只影响结果写回哪里;传给 TA-Lib 的仍是完整的收盘价数组。TA-Lib 本身需要回看窗口,所以不能只传一个点,但也不必传全部,见第 7 节。

主图蜡烛与 MACD、KDJ 两个副图

示意数据:副图 MACD(DIF、DEA 与柱)和 KDJ(K、D、J)。推送到来时只为最后一根写回新值,输入仍是完整的收盘价序列。

捏合缩放用一个自定义的手势识别器,蜡烛宽度夹在最小、最大值之间,以捏合中心为锚点;分时图不允许捏合。长按显示十字光标,单击切换显隐。

4.7 K 线页上的交易登录

K 线页底部可以直接下单,下单前要有交易会话。这部分接入的是交易登录管理器:

  • K 线页新增交易登录逻辑;
  • 改为调用统一的交易登录入口,不再直接用登录弹窗;
  • K 线页与证券、期货下单页改为继承带网络与交易连接状态提示的页面基类;
  • 证券强制改密时,离开改密页后再次弹出强制改密框;
  • 图表页输错交易密码时只保留一处失败提示。
交易网关交易登录管理K 线页用户交易网关交易登录管理K 线页用户alt[成功][密码错误]点买入 / 卖出统一登录入口(账户类型)交易登录会话号成功回调,进入下单失败由登录管理给出失败提示失败回调,不再重复提示

5. 行情列表:只订阅看得见的行

行情列表上的几种手段:

手段 做法
可见行订阅差分 只订阅屏幕内的代码;滚动后退订离开屏幕的、订阅新进入的
实时与延时分组 按是否有实时权限把代码分成两组,分两次订阅
「是否实时」页面级缓存 在页面里算一次,不再每个代码都去读服务端配置
去掉懒加载里的数据库查询 列表模型懒加载时不再每个都查一次合约表,改为按市场类型直接判断
价格聚合 快照、盘口、基础信息合并成一个价格模型回调,页面只关心一个回调
行情连接订阅管理列表行情连接订阅管理列表滚动后,当前可见代码和上次的可见集合做差退订离开屏幕的订阅新进入的(实时与延时分开)只推送可见行

其中第四条是一次专门的市场页性能修复:原来每个列表模型在懒加载某个属性时都会查一次本地数据库里的合约表,一屏几十行就是几十次查询。另外「是否实时行情」原来每个代码都要读一次服务端配置,而这个读取在配置为空时还会顺带触发一次账户信息请求。


6. 对比与取舍

问题 另一种做法 采用的做法 代价
推送与用户拖动冲突 每条推送都重绘并滚到最右 只在最右侧且没在滚动时重绘;滚动期间缓存 需要 10 秒兜底防回调丢失
绘制量 一次画完全部历史 只画可见区 每次滚动重建可见图层
坐标换算 绘制时现算 点位、颜色缓存在模型上 模型变重,缩放后要重算
指标 每次全量重算 只写回最后一根 仍传完整数组
列表订阅 全量订阅 可见行差分 新进入的行要等订阅生效才有推送
解码 主线程 专用队列 要注意回主线程再改界面

7. 可以改进的地方

下面每一条都是方向,不是已经做完的改动。

现在的做法 问题 改进方向
每次滚动都移除并重建可见图层 滚动时创建和销毁图层的开销 复用图层,只更新路径;或在单个图层里一次画完可见区
推送来一条处理一条 推送密集时主线程忙 用 CADisplayLink 把同一帧内的推送合并成一次重绘
指标增量仍传完整数组 数据越多越慢 只取「窗口长度」那么多的尾部数据做增量
滚动结束靠回调 + 10 秒兜底 兜底期间推送一直积压 用滚动视图的状态轮询或在主循环空闲时回放
只订阅可见行 新进入的行要等订阅生效 预订阅可见区上下各一屏
现在 重建图层 / 逐条处理 / 全量 数组复用图层,只换路径按帧合并推送指标只取尾部窗口预订阅相邻一屏推送密集时也保持流畅