1.起因
起因是年前看到了一篇Rust + iOS & Android|未入门也能用来造轮子?的文章,作者使用Rust做了个实时查看埋点的工具。其中作者的一段话给了我启发:
无论是 LookinServer 、 Flipper 等 Debug 利器,还是 Flutter / Web Debug Tools,都是在电脑上调试 App。那我们也可以用类似的方式,把实时埋点数据显示在电脑上,不再局限于同一块屏幕。
我司目前的埋点走查是在测试盒子中有一个埋点查看页面,Debug包在数据上报的同时会将信息临时保存起来。当进入这个页面时会以列表的形式展示出来。并且iOS 和Android的页面展示和使用方式也略有不同。
后面我觉得这样进入退出页面查看不方便,就将页面改成了悬浮窗。虽然方便了一些,但是也发现了新的问题:
- 手机上屏幕大小有限,悬浮窗只有屏幕的一半,可展示信息有限。
- 悬浮窗会遮挡页面,有时不便于点击页面上的按钮。
刚好前阵子升级了手机系统到Android 13,发现log在控制台都打印不出来了(后面发现App适配到13就正常了。。)。所以有了一个想法,使用Rust通过WebSocket
进行数据发送,使用Flutter实现服务端接收App发送的信息并显示出来。
当然了,如果我们的应用是flutter写的,可以直接使用Dart的ffi来直接调用Rust函数。这个我后面有时间会单独写一篇来分享。
2.实现
之所以选择Rust
与Flutter
是看中它们的跨平台能力。使用Rust进行WebSocket
数据发送,就不用Android和iOS端去重复开发这个功能,只需要简单调用即可,并且Rust有许多开箱即用的库。
Flutter的跨平台能力就更不用说了。比如这个小工具我就可以一套代码输出Windows和macOS两个平台的安装包,保证接收端逻辑和UI的一致。
发送端
Rust部分
关于Rust库的打包以及双端的使用可以看我上一篇分享的Rust库交叉编译以及在Android与iOS使用。这里主要说一下具体的实现代码。
首先是添加WebSocket 库 ws-rs依赖到Cargo.toml
文件:
[dependencies]
ws = "0.9.2"
# 全局的静态变量
lazy_static = "1.4.0"
实现代码如下:
use std::collections::HashMap;
use std::sync::Mutex;
use std::{ffi::CStr, os::raw::c_char};
use ws::{connect, Handler, Sender, Handshake, Result, Message, CloseCode, Error};
use ws::util::Token;
#[macro_use]
extern crate lazy_static;
lazy_static! {
static ref DATA_MAP: Mutex<HashMap<String, Sender>> = {
let map: HashMap<String, Sender> = HashMap::new();
Mutex::new(map)
};
}
struct Client {
sender: Sender,
host: String,
}
impl Handler for Client {
fn on_open(&mut self, _: Handshake) -> Result<()> {
DATA_MAP.lock().unwrap().insert(self.host.to_owned(), self.sender.to_owned());
Ok(())
}
fn on_message(&mut self, msg: Message) -> Result<()> {
println!("<receive> '{}'. ", msg);
Ok(())
}
fn on_close(&mut self, _code: CloseCode, _reasonn: &str) {
DATA_MAP.lock().unwrap().remove(&self.host);
}
fn on_timeout(&mut self, _event: Token) -> Result<()> {
DATA_MAP.lock().unwrap().remove(&self.host);
self.sender.shutdown().unwrap();
Ok(())
}
fn on_error(&mut self, _err: Error) {
DATA_MAP.lock().unwrap().remove(&self.host);
}
fn on_shutdown(&mut self) {
DATA_MAP.lock().unwrap().remove(&self.host);
}
}
#[no_mangle]
pub extern "C" fn websocket_connect(host: *const c_char) {
let c_host = unsafe { CStr::from_ptr(host) }.to_str().unwrap();
if let Err(err) = connect(c_host, |out| {
Client {
sender: out,
host: c_host.to_string(),
}
}) {
println!("Failed to create WebSocket due to: {:?}", err);
}
}
#[no_mangle]
pub extern "C" fn send_message(host: *const c_char, message: *const c_char) {
let c_message = unsafe { CStr::from_ptr(message) }.to_str().unwrap();
let c_host = unsafe { CStr::from_ptr(host) }.to_str().unwrap();
let binding = DATA_MAP.lock().unwrap();
let sender = binding.get(&c_host.to_string());
match sender {
Some(s) => {
if s.send(c_message).is_err() {
println!("Websocket couldn't queue an initial message.")
};
} ,
None => println!("None")
}
}
#[no_mangle]
pub extern "C" fn websocket_disconnect(host: *const c_char) {
let c_host = unsafe { CStr::from_ptr(host) }.to_str().unwrap();
DATA_MAP.lock().unwrap().remove(&c_host.to_string());
}
简单实现了连接,发送,断开连接三个方法。思路是连接成功后会将发送结构体(Sender)保存在Map中,每次发送时先检查是否连接再发送。这样也就实现了连接多台设备,一对多发送的功能。
Android还需要添加对应的JNI方法:
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
pub mod android {
extern crate jni;
use self::jni::objects::{JClass, JString};
use self::jni::JNIEnv;
use super::*;
#[no_mangle]
pub unsafe extern "C" fn Java_com_weilu_utils_EventLogUtils_sendMessage(
env: JNIEnv,
_: JClass,
host: JString,
message: JString,
) {
send_message(
env.get_string(host)
.expect("invalid pattern string")
.as_ptr(),
env.get_string(message)
.expect("invalid pattern string")
.as_ptr(),
);
}
#[no_mangle]
pub unsafe extern "C" fn Java_com_weilu_utils_EventLogUtils_connect(
env: JNIEnv,
_: JClass,
host: JString,
) {
websocket_connect(
env.get_string(host)
.expect("invalid pattern string")
.as_ptr(),
);
}
#[no_mangle]
pub unsafe extern "C" fn Java_com_weilu_utils_EventLogUtils_disconnect(
env: JNIEnv,
_: JClass,
host: JString,
) {
websocket_disconnect(
env.get_string(host)
.expect("invalid pattern string")
.as_ptr(),
);
}
}
至此,发送端部分完成。打包集成进项目就可以使用了。
Android部分
Android端调用代码如下:
public class EventLogUtils {
static {
System.loadLibrary("event_log_kit");
}
private static native void sendMessage(final String host, final String message);
private static native void connect(final String host);
private static native void disconnect(final String host);
private static List<String> addressList = null;
public static List<String> getAddressList() {
return addressList;
}
/**
* 保存 IP 地址,传空时断开所有连接
*/
public static void saveAddress(String address) {
if (TextUtils.isEmpty(address)) {
if (addressList != null) {
for (String url : addressList) {
disconnect(url);
}
}
addressList = null;
return;
}
// 多个地址逗号隔开
if (address.contains(",")) {
addressList = new ArrayList<>(Arrays.asList(address.split(",")));
} else {
addressList = new ArrayList<>();
addressList.add(address);
}
for (String url : addressList) {
// 子线程调用,可替换为其他方案,这里使用了线程池
Executor.getExecutor().getExecutorService().submit(new Runnable() {
@Override
public void run() {
// 循环,如果意外断开,自动重连
while (addressList != null) {
connect("ws://" + url);
}
// 工具连接彻底断开
}
});
}
}
/**
* 发送信息
*/
public static void sendMessage(String message) {
if (addressList == null) {
return;
}
for (String url : addressList) {
sendMessage("ws://" + url, message);
}
}
}
代码也比较简单,连接方法在子线程调用,如果发现连接断开会自动重连。
iOS部分就不具体说明了,实现思路一样的。
接收端
首先是发送数据的定义,发送的是json格式字符串。定义的主要参数如下:
class EventLogEntity {
/// event/log
String type = '';
/// 事件名称或log tag
String? name;
/// 手机型号
String? deviceModel;
/// 时间戳
int time = 0;
String data = '';
...
}
type
:用于区分数据类型,目前分为埋点事件与log。name
:事件名称或log tag,用于数据的筛选。deviceModel
:设备名用于区分数据来源,如果有多个设备同时发送数据可以便于分类。time
:时间戳,用于数据排序。
其他参数可以根据自己的需求添加,比如log的等级,数据展示时展开或者收起。
UI组件我使用了fluent_ui,它提供了原生Windows应用风格的组件,比较适合桌面端程序。状态管理使用flutter_riverpod。
具体的代码实现就不多说了,主要说一下核心的数据接收部分。
// https://doc.xuwenliang.com/docs/dart-flutter/2499
class WebSocketManager{
HttpServer? requestServer;
Future startWebSocketListen() async {
final String ip = '192.168.31.232';
final String port = '51203';
stopWebSocketListen();
//HttpServer.bind(主机地址,端口号)
requestServer = await HttpServer.bind(ip, int.parse(port)).catchError((error) {
debugPrint('bind error: $error');
});
await for(HttpRequest request in requestServer!) {
serveRequest(request).catchError((error){
debugPrint('listen error: $error');
});
}
}
void stopWebSocketListen() {
requestServer?.close();
requestServer = null;
}
Future serveRequest(HttpRequest request) {
//判断当前请求是否可以升级为WebSocket
if (WebSocketTransformer.isUpgradeRequest(request)) {
//升级为webSocket
return WebSocketTransformer.upgrade(request).then((webSocket) {
//webSocket消息监听
webSocket.listen((msg) async {
debugPrint('listen:$msg');
if (webSocket.closeCode == null) {
// 这里可以回复客户端消息
webSocket.add('收到');
}
// 可以在这里解析数据,刷新页面
...
});
});
} else {
return Future((){});
}
}
}
然后为了便于使用,避免使用者自己查询填写ip,我们需要获取当前设备ip地址:
Future<String> getDeviceIp() async {
String ip = "";
if (!kIsWeb) {
for (var interface in await NetworkInterface.list()) {
for (var address in interface.addresses) {
ip = address.address;
}
}
}
return ip;
}
端口可以给个默认值或者自己随便输入一个,然后可以用shared_preferences
插件保存用户配置。下次启动时就自动连接了。
手机端可以实现一个输入连接地址的页面,输入电脑端的ip和端口号后就可以发送数据了。或者扫描二维码连接。
3.成果展示
目前实现功能如下:
- 可同时接收多台设备发送数据,数据按机型名称分类展示。
- 数据的筛选,搜索(关键字高亮)。
- 搜索记录的保存。
- json数据格式化展示。
因为小工具在公司内部使用,所以就不开源完整的代码了。有了文章中的核心代码,你可以根据自己的需求实现。也不必局限于这些功能,你完全可以通过Rust和Flutter的跨平台能力开发更多功能,本篇也只是抛砖引玉。
如果本篇对你有所启发帮助,不妨点赞支持一下。如果你有好的想法,也欢迎评论交流。
4.参考
- Rust + iOS & Android|未入门也能用来造轮子?