港美股与期货交易App的设计架构
iOS 工程师
概述
这是一款港股、美股与期货的一站式交易 App:行情(市场、自选、K 线、排行)、交易(证券与期货下单、持仓、委托、资金)、开户与出入金(H5)、资讯、新股和员工期权。客户端为纯 Swift,界面全部纯代码。
网络上是一条 HTTP 加两条长连接:HTTP 承接账户与业务接口;行情走原生 WebSocket,交易走 Socket.IO 连到交易网关;两条长连接的负载都是 Protobuf。账户上是两级会话:先登录 App 账户,下单前还要再登录交易账户。
下面依次是业务结构、技术架构、关键流程和几处值得展开的设计,涉及三条网络通道的首版骨架、交易网关协议对接与事件分发、交易登录与二次认证、交易会话保活与被踢、交易首页与资金数据中心、资金查询的请求频率控制,以及行情推送、K 线、凭证续期、H5 桥和合约码表。
业务全景
同一套客户端也可以按业务模块来看。上面按用户路径串起来,下面按业务模块切开:行情、交易、开户与资金、资讯与运营。
1. 业务结构
1.1 架构演进
项目是从零开始的新 App。起步阶段先把HTTP 客户端、行情 WebSocket、交易 Socket.IO 三条通道的首版骨架搭起来,之后再逐步加固长连接、接通交易协议、补齐交易业务。
1.2 模块一览
| 模块 | 职责(一句话) | 主要技术点 |
|---|---|---|
| 交易登录(证券 / 期货) | 下单前登录交易账户 | 应答驱动、二次认证、改密、请求序号区分静默登录 |
| 面容 / 指纹交易登录流程 | 用生物识别代替输交易密码 | 生物特征变更即解绑、有效期、密码错误自动解绑 |
| 交易会话保活与被踢 | 保持在线,被挤下线时提示 | 10 秒心跳、会话号与设备标识比对、按页面提示 |
| 交易首页与资金数据中心 | 多账户资金、总资产、浮动盈亏总览 | 推送 × 持仓 × 汇率现算、数据齐全才刷新 |
| 交易数据请求管理 | 统一决定「什么时候查什么」 | 按页面场景组合查询、合并窗口与强制通道 |
| 交易网关协议 | 请求、应答与主推 | 信封与事件;请求与应答模型、十几个应答事件的分发、平台与设备字段 |
| 行情推送与列表 | 市场、自选、排行实时刷新 | 只订阅可见行、价格聚合 |
| K 线 | 自绘 K 线、分时、十余种指标 | CAShapeLayer 局部绘制、TA-Lib |
| App 账户与凭证续期 | 登录、续期、登出 | access / refresh、预刷新、单飞 |
| H5 混合 | 开户、入金、活动 | 约 30 种桥接消息 |
| 合约码表 | 本地合约数据库 | WCDB、预置库、增量更新 |
| 基础设施 | 网络、主题、多语言、组件库、监控 | 见第 2 节 |
模块之间的依赖是单向的:业务模块调用基础层;长连接层不理解业务,只把应答投递出去。
2. 技术架构
2.1 分层
从上到下是四层:页面壳、业务层、网络层、数据层。页面基类统一了导航栏、主题、生命周期、取消请求和网关状态提示。
2.2 一条 HTTP,两条长连接
| 通道 | 对端 | 负载 | 鉴权 | 保活与重连 |
|---|---|---|---|---|
| HTTP | 业务网关,按路径前缀分服务 | JSON | 请求头带用户凭证 | 15 秒超时;长任务 300 秒 |
| 行情长连接 | 行情服务器,按市场下发 | Protobuf 信封 | 用行情专用凭证登录 | 10 秒心跳;节流重连;后台断开 |
| 交易长连接 | 交易网关的三个命名空间 | Protobuf 事件负载 | 握手头带用户凭证 | 库内加应用层重连;10 秒账户心跳 |
为什么是两条。 后端是两套服务,凭证不同,流量模型也不同:行情是高频单向推送,交易是请求-应答加主推,还要按期货、证券两套柜台隔离。代价是重连和心跳各写一遍。选型、信封、发送策略和分发的细节见两条长连接。
交易连接的门控。 先连鉴权命名空间,成功之后才初始化期货和证券命名空间;鉴权失败就强制续期,并在冷却期内忽略新的连接请求,避免拿旧凭证反复鉴权。
信封。 两条长连接都是「类型 + 序号 + 二进制负载」。行情信封约 70 种类型值,覆盖心跳、登录、查询、订阅与退订和各类主推。交易信封额外带平台和设备标识,应答与主推用两个不同的事件名区分;请求序号由客户端生成、应答原样带回。
协议源文件以子模块跟踪后端的协议仓库,一个脚本拉取并生成 Swift 代码,后端用同一份协议生成自己的代码。交易网关的请求与应答模型、十几个应答事件的分发、新版交易协议的适配,以及发送时附带平台与设备标识都在这一层。字段、请求序号和应答的对号方式见交易消息协议。
2.3 数据怎么分发给页面
项目里没有 Rx,也没有通用的事件总线。分发靠三种很朴素的手段:
- 单例连接 + 一个 weak 代理。 每条连接只认一个当前代理,页面在出现时把自己切成代理,全仓约 42 处这样的切换。同一时刻只有一个页面处理推送。
- 二次扇出。 市场容器拿到数据后,再转给当前子页和即将出现的子页;交易侧有一个中间层同时代理行情和交易两条连接,再派生出持仓、委托、成交、快照几类回调。
- 通知广播状态。 期货与证券网关的连接状态、凭证变化等用
NotificationCenter广播。网络与网关状态的监听收进页面基类,子页面不再各自注册。交易登录的结果则直接写进登录管理器的属性,由属性观察器驱动后续处理。
单代理加扇出的代价是同一时刻只有一个消费者、切换时机要靠约定守住;属性观察器让长连接层不必懂业务,但处理逻辑是隐式的。几种投递方式的对比见两条长连接第 4.5 节。
2.4 三级凭证
| 凭证 | 从哪来 | 用在哪 | 失效时 |
|---|---|---|---|
| 用户凭证 | App 登录 | HTTP 请求头、交易长连接建连、给 H5 | 续期凭证失效才登出,弱网不登出 |
| 行情凭证 | 账户信息接口,随服务器地址下发 | 行情长连接登录 | 过期就重拉账户信息 |
| 交易会话 | 交易网关登录应答 | 交易请求、账户心跳 | 掉线、被踢、登出时置空,只下线交易,不登出 App |
续期的触发点是请求前、页面出现、启动与回前台、网络恢复,剩余有效期不足就续,续期请求同一时刻只发一次;服务端认证失败时强制续期,但不重放原请求。用户凭证一变化,交易长连接就用新凭证重连。详见 凭证续期。
2.5 启动到出数据
- 地址从哪来。 业务接口是一个网关域名,按路径前缀分到各服务;交易网关也是域名;行情服务器是服务端按市场下发的节点,客户端直连,并把节点信息落盘缓存。
- 客户端不选线。 没有测速、HTTPDNS、内地与香港线路切换,也没有失败切备用地址。如果有就近调度,是服务端下发节点时做的。
2.6 缓存与持久化
| 方式 | 用途 |
|---|---|
| 对象归档 | 用户模型,包括两种用户凭证 |
| WCDB | 合约码表:随包预置,启动后按版本与时间戳增量更新 |
| 钥匙串 | 设备标识、生物识别绑定的交易密码(加密后保存) |
| 偏好设置 | 开关与引导状态 |
| 磁盘缓存 | 行情节点等配置 |
2.7 UI
- MVVM 变体。页面基类统一导航栏、主题、生命周期:页面析构时取消它发起的 HTTP 请求,回调前持有者已经释放就丢弃结果。
- 组件库:按钮、自动翻页列表、页面容器、手势冲突处理、空态、弹窗、下拉刷新。
- 双维度主题:明暗 × 红涨绿跌;简体、繁体、英文三套文案。
2.8 H5 桥与码表
- H5 桥:约 30 种消息,覆盖取凭证、App 信息、人脸识别、证件拍照、唤起原生登录、新股下单、进入行情或下单、自选读写、分享、日历等。H5 把回调函数名传过来,原生处理完再调用它。凭证不是提前注入,而是 H5 通过桥来取。
- 码表与发布:全量合约随包发布,首次启动不必等下载。生产码表的更新是一条半手工流程;打包按分支分开发、预发布、生产和商店几种包。
2.9 依赖与工程
- 主要依赖:Alamofire、Starscream、Socket.IO-Client-Swift、SwiftProtobuf、WCDB.swift、SnapKit、Kingfisher、HandyJSON / SwiftyJSON、TA-Lib。
- 工程:CocoaPods;SwiftLint 强约束(禁强解包、禁强转、代理必须 weak、禁 IB);Socket.IO 与下拉刷新用的是自维护的分支。Jenkins 按分支定时打包。另有自研的内存泄漏检测(页面退出后延迟检查是否释放)和卡顿监控。
2.10 安全(概念级)
- 两级会话:App 账户之外,交易还要单独登录,可能还要短信或邮箱二次认证,密码过期时强制修改。
- 生物识别:系统生物特征一变就解绑;有有效期;用本地保存的密码登录失败时自动解绑。
- 交易请求带平台与设备标识,掉线和登出类应答只处理本设备的。
3. 关键流程
3.1 交易登录
登录管理器把期货和证券两套柜台的十几种应答收到一处处理:二次认证、未绑定认证方式、密码错误、必须改密、客户状态不允许、请求过于频繁等。开启生物识别时会先用交易密码做一次静默登录,它的应答靠请求序号认出来,只更新会话号,不走界面分支。登录入口还有 1 秒的防重入闸门。
3.2 被其他设备挤下线
单点登录由网关保证,客户端要做的是认出「是不是自己」并决定怎么提示。证券侧靠比对会话号;期货侧的掉线主推带平台与设备标识,只处理本设备的。应答里出现「未登录」或「会话校验失败」也按掉线处理。只有在交易 Tab,或在行情 Tab 且导航栈里有下单页时才弹框;其他页面不打扰,下次进交易再要求登录。
3.3 交易首页的一次刷新
4. 几处值得展开的设计
下面挑七处按技术深度展开,每处一句问题、一句方案,完整过程见各篇案例。
案例 1:两条长连接、Protobuf 与数据分发
- 问题:行情和交易是两套后端、两种凭证、两种流量模型,还要在没有 Rx 和事件总线的情况下把数据送到正确的页面。
- 方案:行情走原生 WebSocket,交易走 Socket.IO 的三个命名空间;两边都是「类型 + 序号 + 二进制负载」的信封。分发用单例连接加单个 weak 代理,容器和中间层二次扇出,状态走通知。三条通道的首版骨架,以及交易网关的请求应答模型与事件分发
- 详细案例:/ios/cases/trade-dual-long-connection
案例 2:交易消息协议:请求与应答的结构和请求序号
- 问题:长连接上没有「一个请求对应一个响应」的边界:回来的可能是这次的应答、服务端的主推,也可能是另一台设备那次请求的结果。
- 方案:信封加 Protobuf 负载两层;请求序号由客户端生成、应答原样带回,只在静默绑定登录等少数场景比对;其余应答和全部主推按类型分发,交易侧用两个事件名区分应答与主推,多设备时先按平台和设备标识过滤。超时、迟到和乱序应答的处理单独列为建议。
- 详细案例:/ios/cases/trade-message-protocol
案例 3:交易会话:二次登录、保活与被踢
- 问题:App 登录之外还有一级交易会话。两套柜台的应答、二次认证和改密各不相同;同一交易账户只允许一台设备在线,被挤下线时不能把用户踢出 App。
- 方案:应答驱动的登录处理,请求序号区分静默登录与用户登录;登录后 10 秒账户心跳;靠会话号和设备标识判断是否被挤;掉线只下线交易会话,并按当前页面决定是否提示。交易登录、二次认证、保活与被踢
- 详细案例:/ios/cases/trade-session-login-kickout
案例 4:交易资金查询的请求频率控制
- 问题:交易首页的资金数据有定时器、推送、页面出现、下拉刷新、重连多种触发源,同一个查询会在短时间内被重复发出。
- 方案:先给查询加计数,再按来源分层:轮询先降频再取消;被动触发走 1 秒合并窗口,用户动作走强制通道;盈亏推送引起的刷新和用户刷新分开;盈亏等齐再画,最多等一个上限。计数、强制通道、按场景的请求管理和推送分流
- 详细案例:/ios/cases/trade-request-frequency-control
案例 5:网络优化:从现有网络层出发
- 问题:交易 App 在地铁、电梯、Wi-Fi 与蜂窝切换时最容易出问题:连接看起来还在、其实已经死了;请求叠在一起;弱网下超时和重试没有统一口径。
- 方案:先写清项目里已经有的(网络可达性监听、回前台与网络变化后的连接检查、重连节流、请求单飞与合并窗口、长连接压缩、二进制协议),再把还没做的单独列为建议(网络类型切换的主动重连、请求头瘦身、响应压缩、分级超时与退避重试)。
- 详细案例:/ios/cases/trade-network-optimization
案例 6:access / refresh 凭证续期
- 问题:用户凭证有效期短,多个页面会同时触发续期;弱网下不能把人踢出去。
- 方案:剩余不足 20 分钟就提前续期,同一时刻只发一次;服务端认证失败时兜底强制续期;只有续期凭证本身失效才登出。和另一个项目「不续期、过期就登出」的做法放在一起对比。
- 详细案例:/ios/cases/trade-token-refresh
案例 7:实时 K 线与行情性能
- 问题:推送频繁时既不能打断用户拖动看历史,也要保持滑动流畅;列表行数多,全量订阅浪费带宽和 CPU。
- 方案:解码离开主线程;时间戳相同就替换最后一根,否则追加,分时图按交易时间表补空分钟;只画可见区域;滚动时推送先缓存,停下再回放;列表只订阅可见行。
- 详细案例:/ios/cases/trade-kline-quote-performance
5. 已经落地的优化
- 交易首页资金查询:轮询从 3 秒改为 10 秒,之后改为事件驱动;页面出现时距上次刷新不足 10 秒就不再查。
- 合并窗口与强制通道:被动触发的资金查询 1 秒内只发一次,用户动作绕过窗口。
- 盈亏推送与用户刷新分流:修复了下拉刷新被盈亏推送「吃掉」的问题。
- 网关状态统一监听:交易首页、证券主页、期货主页、期货资金详情共用页面基类的状态提示。
- 长连接:重连节流、鉴权失败冷却、所有交易连接操作进一条串行队列。
- 行情列表:只订阅可见行;市场页不再逐行查码表。
- HTTP:请求跟随页面生命周期取消。
- 续期:去掉失败后的自动重试,改为等下一个自然触发点。
6. 还可以做的
- 凭证:认证失败时把请求挂起,续期成功后重放;续期用
actor保证唯一在途;凭证从归档文件迁到钥匙串。 - 生物识别:改用带生物识别访问控制的钥匙串项,「特征变化即失效」交给系统判断。
- 长连接:两条连接抽一个公共的连接状态机;单代理改成按主题的多播订阅表。
- 消息协议:加以请求序号为键的在途请求表,给下单类请求挂超时,迟到应答丢弃,乱序只认最新。
- 交易状态:把属性观察器里的处理改成显式状态枚举加纯函数,补单元测试。
- 金额:资金与盈亏改用
Decimal,计算移出主线程。 - 网络切换:区分 Wi-Fi 与蜂窝的切换,切换后主动重建长连接,而不是只检查「是否已连接」。
- 工程:码表生成脚本化进持续集成,商店包自动上传。
7. 重点模块与设计
每一条都是「要解决什么、设计是什么、为什么这样」。细节在对应案例里。
- 行情和交易分成两条长连接。 问题是两套后端、两种凭证、两种流量。设计是行情原生 WebSocket,交易 Socket.IO 按柜台分命名空间,信封统一。首版骨架与交易协议对接。见 两条长连接。
- 应答要能回到发起方。 问题是长连接上应答、主推和别的设备的结果混在一起。设计是信封带请求序号与设备标识,按类型分发为主、少数场景比对序号。见 交易消息协议。
- 交易登录收成一个入口。 问题是两套柜台、十几种应答、二次认证和生物识别叠在一起。设计是应答驱动的处理加请求序号。交易登录与二次认证。见 交易会话。
- 被挤下线只下线交易。 问题是交易会话和 App 账户是两级,交易掉线不能把人踢出 App。设计是会话号与设备标识比对,按页面决定是否提示。保活与被踢。见 交易会话。
- 同一个查询不要被重复发。 问题是触发源太多。设计是被动触发合并、用户动作强制、推送与用户刷新分流。资金查询的频率控制。见 请求频率控制。
- 浮动盈亏要客户端现算。 问题是柜台只给静态资金和持仓。设计是行情 × 持仓 × 汇率,港美股都到齐才刷新,等不到就用现有数据。资金数据中心。
- 凭证提前续期。 问题是有效期短、并发多。设计是 20 分钟阈值、单飞、兜底强刷。见 凭证续期。
- K 线不打断用户。 问题是推送频繁而用户在看历史。设计是局部绘制、滚动期缓存推送。见 实时 K 线。