介绍
基于鸿蒙api9,使用websocket实现的基于protobuf实时行情系统
思考
实现所需要的技术点
- 将proto文件生成静态类
- 使用websocket与服务器通信
- UI层消息订阅
- 生命周期处理
一、将proto文件生成静态类
1、在entry目录下新建一个文件夹proto ,里面放.proto
结尾的文件
2、引入protobufjs依赖
这里一开始是想引用@ohos/protobufjs
2.0版本 但是最低支持api11好像。所以改成直接使用protobufjs,因为我门项目只需要生成静态类使用,不需要动态的使用protobufjs的其他功能。
在oh-package.json5添加依赖 点击sync now
java
"dependencies": {
"protobufjs": "^7.2.4"
}
3、自动化生成
然后我们希望写一个脚本如果修改proto文件夹中的.proto
,通过脚本就能自动生成ts静态类。在目录下新建package.json文件,写入脚本
json
{
"scripts": {
"protots": "pbjs -t static-module -w es6 -o src/main/proto.js proto/*.proto\npbts -o src/main/proto.d.ts src/main/proto.js"
}
}
运行脚本会在指定目录下生成两个文件,接下来我们的行情解析就能使用这个ts类进行解析
二、使用websocket与服务器通信
1、通信数据的定义:我们的socket通信一般是 数据头+数据体 ,数据头部不同业务不同公司定义不一样,但是基本包含了packetNo
,发消息与收消息通过这个packetNo进行配对,一般还包含一个type,定义不同的类型。下面Demo我们以如下数据结构进行开发:
数据头固定12个字节,包含四个字段[
packetLength
,packetNo
,type
,isSub
]isSub代表是订阅还是取消订阅(实际项目中并不是这样玩的,demo简单处理)
type代表发送的数据类型,假设1是认证 2是实时股价等...
1、导入官方的websocket依赖
import webSocket from '@ohos.net.webSocket'
具体使用参考官方文档我是文档
里面主要处理了验证、心跳、收发信息等功能。 大概使用如下
ts
// 这里的HqAppDef就是使用protojs生成的proto.d.ts文件
import { HqAppDef } from '../../proto'
// 发送认证
private auth() {
let authReq = new HqAppDef.AuthReq()
authReq.orgcode = xxx
authReq.appname = xxx
authReq.token = xxx
authReq.apptype = "android"
authReq.tag = xxx
let byte = HqAppDef.AuthReq.encode(authReq).finish()
let head = new StructHead(byte.byteLength,0,1,1) // 数据结构定义后面有介绍
this.socket.send(new Uint8Array([...head.toArray(), ...byte]).buffer)
logger.info(TAG, `认证==========================发送`)
}
xxx.encode(yyy).finish()
以及head.toArray()
类型是Uint8Array
,拼接在一起转成ArrayBuffer
发送给服务器
发送心跳使用的是setInterval
,可以通过clearInterval
进行取消
数据的接收
ts
this.socket.on('message', (err: Error, value: ArrayBuffer) => {
if (err) {
reject(err)
}
// 数据头
const head = StructHead.toHead(value.slice(0, StructHead.HEAD_SIZE))
// 数据体
const body = value.slice(StructHead.HEAD_SIZE, value.byteLength)
// 打印日志
logger.info(TAG, `接收<=${parserManager.forDebug(value)}`)
// 解析管理类,根据不同类别信息调用各自专属的解析类
parserManager.parser(head, body)
}
parser(head: StructHead, body: ArrayBuffer) {
switch (head.type) {
case xxx:
xxx.parserMessage(head, body)
break
case yyy:
yyy.parserMessage(head, body)
break
case zzz:
break
...
}
}
2、数据头处理
首先数据头一半是固定字节大小的,简单点先假设是12。里面有4个字段,[packetLength=4
,packetNo=4
,type=2
,isSub=2
]
ts
export class StructHead {
static HEAD_SIZE = 12
packetLength: number
packetNo: number
type: number
isSub: number
constructor(packetLength: number, packetNo: number, type: number, isSub:number) {
this.packetLength = packetLength
this.packetNo = packetNo
this.type = type
this.isSub = isSub
}
toArray(): Uint8Array {
let dataView = new DataView(new ArrayBuffer(HEAD_SIZE))
dataView.setInt32(0, this.packetLength)
dataView.setInt32(4, this.packetNo)
dataView.setInt16(8, this.type)
dataView.setInt16(10, this.isSub)
return new Uint8Array(dataView.buffer)
}
}
这样就可以方便的构建出数据头,也能方便转换成ArrayBuffer
那么收到的信息截取出前12个字节如何转换成StructHead呢?很简单,反过来get一下就可以
ts
static toHead(buffer: ArrayBuffer): StructHead {
let dataView = new DataView(buffer)
let head = new StructHead(
dataView.getInt32(0),
dataView.getInt32(4),
dataView.getInt16(8),
dataView.getInt16(10)
)
return head
}
3、数据体处理
截取出来的数据体通过proto.d.ts对应的类中的decode
方法进行解析
ts
parserMessage(head: StructHead, body: ArrayBuffer) {
...
const bodyResp = HqAppDef.xxx.decode(new Uint8Array(body))
...
}
涉及到收发包packetNo对应的问题思路就是整个消息的消息头携带了packetNo,服务端返回的消息头也携带packetNo,各个Parser发送消息时存储此条消息的packetNo,接收消息通过parserManager.parser(head, body)
分发到对应的Parser,在对应的Parser存储里找到对应的发送方
ts
// 简略代码
AParser {
pairHashMap = new HashMap()
send(info) {
// 获取一个唯一的id
const packetNo = getPacketNo(info)
pairHashMap.set(packetNo, stockContractVo.uniqueId())
}
receive(head,body){
pairHashMap.get(head.packetNo)
// 知道传递给谁了
}
}
三、UI层消息订阅
将数据传到viewmodel。先定义一下需要传递出去的信息
ts
export class PbMessage<T> {
head: StructHead
body: T
tag: string = ""
extra: any | null = null
constructor(head: StructHead, body: T, tag: string = "", extra: any | null = null) {
this.head = head
this.body = body
this.tag = tag
this.extra = extra
}
}
传递出去的信息希望通过观察者模式进行观察,简单写点代码,实际根据业务及架构自行设计,包括模仿LiveData
粘性订阅等
ts
export interface IParser {
parserMessage(head: StructHead, body: ArrayBuffer)
}
export abstract class BaseParser<T> {
...
callbackId = -1
// 根据不同的消息类型区分开
callbackHashMap: HashMap<number, List<ParserEventCallback<any>>> = new HashMap()
clearCache() {
this.callbackHashMap.clear()
this.callbackId = -1
}
on<T>(type: number, callback: (message: PbMessage<T>) => void): { off: () => void } {
this.callbackId++
const wrapperCallback = new ParserEventCallback(this.callbackId, callback)
const cacheValue = this.callbackHashMap.get(type)
if (cacheValue) {
cacheValue.add(wrapperCallback)
} else {
const list = new List<ParserEventCallback<T>>()
list.add(wrapperCallback)
this.callbackHashMap.set(type, list)
}
const off = () => {
this.callbackHashMap.get(type)?.remove(wrapperCallback)
}
// 方便外层直接通过.off()去除监听
return { off }
}
// 获取该消息类型下的监听列表,分发消息
protected post(type: number, value: PbMessage<any>) {
const callbackList = this.callbackHashMap.get(type)
if (callbackList) {
for (let callback of callbackList) {
callback?.callback(value)
}
}
}
}
class ParserEventCallback<T> {
callbackId: number
callback: (message: PbMessage<T>) => void
constructor(callbackId: number, callback: (message: PbMessage<T>) => void) {
this.callbackId = callbackId
this.callback = callback
}
}
然后各个类型的parser调用post
传递消息到界面
ts
parserMessage(head: StructHead, body: ArrayBuffer) {
...
const bodyResp = HqAppDef.xxx.decode(new Uint8Array(body))
...
const tag = xxx
this.post(number, new PbMessage(head, bodyResp, tag))
}
在行情基类QuoteViewModel
中统一处理
ts
export class QuoteViewModel {
// 发送订阅消息
subA() {}
subB() {}
// 监听消息回调
onA(){}
onB(callback: (message: PbMessage<xxx>) => void){
// 同一种类型的监听不允许监听多次
this.bOn?.off()
this.bOn = bParser.on(number, (message) => {
logger.info('Loren', `onB=${JSON.stringify(message)}`)
callback(message)
})
}
UI层发送消息以及监听消息
ts
@Entry
@Component
struct QuoteSubscribePage {
quoteViewModel = new QuoteViewModel()
onPageShow() {
this.quoteViewModel.onB((message) => {
...
}
}
build() {
Button("订阅")
.fontSize(20)
.fontWeight(FontWeight.Bold)
.onClick(() => {
this.quoteViewModel.subB(xxx)
})
}
}
至此 websocket收发消息都已经实现
四、生命周期处理
如下场景,A页面切换到B页面的时候,希望A页面取消socket的订阅,减少流量浪费。从B页面返回时,A页面重新订阅刚才的若干。
ts
export class QuoteViewModel {
// 发送订阅消息
aParserRecordHashMap = new HashMap()
...
subA(info) {
aParserRecordHashMap.set
}
onPageShow() {
this.aParserRecordHashMap.forEach((value) => {
// 重新订阅
})
}
onPageHide() {
this.aParserRecordHashMap.forEach((value) => {
// 取消订阅
})
}
}
ts
@Entry
@Component
struct QuoteSubscribePage {
quoteViewModel = new QuoteViewModel()
onPageShow() {
this.quoteViewModel.onPageShow()
}
onPageHide() {
this.quoteViewModel.onPageHide()
}
}
应用放入后台或者熄屏断开socket连接
UIAbility
ts
onForeground() {
// Ability has brought to foreground
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground');
quoteSocketManager.register()
}
onBackground() {
// Ability has back to background
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground');
quoteSocketManager.unRegister()
}
五、运行结果
成功订阅贵州茅台以及五粮液的实时股价
心跳正常
A页面跳转B页面。A页面的股票成功取消订阅
B页面返回A页面,A页面的股票成功重新订阅
六、使用鸿蒙的疑问🤔️
Android使用组件化开发,可以运行组建包方便开发。相同的思想使用鸿蒙开发,不同组件官方推荐使用feature,即hap,但是发现不同UIAbility
之间的跳转有个明显的动画,可以去除吗???所以暂时单工程开发。
试过使用hsp也能进行页面间的跳转,并且没有奇怪动画。使用方式如下 但是发现此hsp内的页面再跳转其他页面只能使用上述方式
所以大家是怎么组件化开发的,欢迎告诉我🤔️