利用Rust与Flutter开发一款小工具

在这里插入图片描述

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.实现

之所以选择RustFlutter是看中它们的跨平台能力。使用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.参考

猜你喜欢

转载自blog.csdn.net/qq_17766199/article/details/128761311