鸿蒙实战:WebSocket+Protobuf开发实时行情

介绍

基于鸿蒙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内的页面再跳转其他页面只能使用上述方式

所以大家是怎么组件化开发的,欢迎告诉我🤔️

相关推荐
Clockwiseee27 分钟前
PHP之伪协议
android·开发语言·php
小林爱32 分钟前
【Compose multiplatform教程08】【组件】Text组件
android·java·前端·ui·前端框架·kotlin·android studio
小何开发2 小时前
Android Studio 安装教程
android·ide·android studio
开发者阿伟2 小时前
Android Jetpack LiveData源码解析
android·android jetpack
weixin_438150992 小时前
广州大彩串口屏安卓/linux触摸屏四路CVBS输入实现同时显示!
android·单片机
CheungChunChiu3 小时前
Android10 rk3399 以太网接入流程分析
android·framework·以太网·eth·net·netd
木头没有瓜3 小时前
ruoyi 请求参数类型不匹配,参数[giftId]要求类型为:‘java.lang.Long‘,但输入值为:‘orderGiftUnionList
android·java·okhttp
键盘侠0073 小时前
springboot 上传图片 转存成webp
android·spring boot·okhttp
江上清风山间明月4 小时前
flutter bottomSheet 控件详解
android·flutter·底部导航·bottomsheet
Crossoads6 小时前
【汇编语言】外中断(一)—— 外中断的魔法:PC机键盘如何触发计算机响应
android·开发语言·数据库·深度学习·机器学习·计算机外设·汇编语言