开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情
前言
本章主要讲述笔者个人在编写 Tauri 应用中遇到的认为值得记录的 App 基础知识,欢迎补充与指正,后续也会根据自己开发过程中的新收获及时更新。
Rust 相关
String 与 str 转换
// &str to String
let message:&str = "Hello, world!";
let message_String: String = String::from(message);
let message_String: String = message.to_string();
let message_String: String = message.to_owned();
// String to &str
let message_str: &str = &message_String;
let message_str: &str = message.as_str();
数组与 Vec
可参考 使用 Vector 储存列表
// 数组 [],长度固定,元素类型不一定相同,数据线性存储
let rust_arr: [u8;5] = [1, 2, 3, 4, 5]; // 指定类型为 u8,指定长度为 5
let index_of_third = rust_arr[2]; // 通过 index 下标访问值
let part_of_arr = &rust_arr[0..2]; // 数组切片,参数不可为负数
// 动态数组Vec, 长度不固定,元素类型相同,数据线性存储
let rust_vec: Vec<u8> = Vec::new(); // 指定类型为 u8
let rust_vec = vec![1, 2, 3, 4, 5]; // 带值创建 vec
let part_of_vec = &vec[2]; // 通过 index 下标访问值
let part_of_vec = rust_vec.get(2); // 通过 index 下标访问值
rust_vec.push(1);
// vec 遍历
for (index, value) in rust_vec.iter().enumerate() {
...
}
// 可改动元素的遍历
for item in &mut rust_vec {
...
}
// 删除部分元素的遍历
rust_vec.retain(|value| -> bool {
...
return false; // 删除该元素
return true; // 保留该元素
})
结构体打印
#[derive(Debug)]
pub struct TodoList {
pub list: HashMap<String, String>
}
println!("{:?}", TodoList);
Clone or Copy
当变量的值需要多处使用而引用不方便时可以使用 Clone
Copy 以笔者目前的浅薄见识来说并不推荐使用,见 What is the difference between Copy and Clone? 及 Rust 当自定义结构体含 String 类型时 无法 derive Copy 应该怎么解决?
#[derive(Debug, Clone)]
pub struct TodoList {
pub list: HashMap<String, String>
}
let todo_list: TodoList = TodoList { list: Hashmap::new() };
let todo_list_clone: TodoList = todo_list.clone();
pritnln!("{:?}", todo_list);
pritnln!("{:?}", todo_list_clone);
文件读写
// 读文件,如不存在则创建
use std::fs::{ File, OpenOptions, read_to_string };
let file_path: &str = "xxxxxx";
let mut target_file: File = OpenOptions::new().read(true).write(true).create(true).open(file_path).unwrap();
// 获取文件长度
let file_size: u64 = target_file.metadata().unwrap().len();
let content: &str = "yyyyyyyyyyy";
// 覆盖式写文件
let write_size: usize = target_file.write(content).unwrap();
// 追加式写文件
let mut target_file: File = OpenOptions::new().append(true).open(file_path).unwrap();
// 读文件
let file_content: String = read_to_string(file_path).unwrap();
调用 shell
// 此处以打开文件夹为例
pub fn open_folder_window(path: &str) {
debug!("try to open {} window", path);
if consts::OS == "linux" {
Command::new("xdg-open")
.arg(path)
.spawn()
.unwrap();
} else if consts::OS == "macos" {
Command::new("open")
.arg(path)
.spawn()
.unwrap();
} else if consts::OS == "windows" {
Command::new("open")
.arg(path)
.spawn()
.unwrap();
}
}
第三方包管理
下述方法不唯一,可自行决定合适的方式, 注意,模块名称最好是 snake case 而不是驼峰
/*
src
| modules
| mod.rs
| explorer.rs
| collector.rs
| my_types.rs
| main.rs
*/
// main.rs 调用 my_types.rs
// in my_types.rs
pub struct Person {
pub name: String,
pub age: u8,
}
// in main.rs
mod my_types;
use my_types::Person;
fn main() {
let jack: Person = Person { name: "Jack".to_string(), age: 18 };
println!("Hello, {}", jack.name);
}
// main.rs 调用 modules 中 explorer.rs 或 collector.rs
// modules/mod.rs
pub mod explores;
pub mod collector;
// explorer.rs
pub fn greet() {
println!("Hello from explorer");
}
// collector.rs
pub fn greet() {
println!("Hello from explorer");
}
// main.rs
mod modules;
use modules::explorer::greet as ex_greet;
use modules::collector::greet as co_greet;
fn main() {
ex_greet();
co_greet();
}
// 同级调用, explores.rs 调用 collector.rs
// explorer.rs
use super::collector::greet as co_greet;
pub fn greet() {
co_greet();
println!("Hello from explorer");
}
本地修改第三方库
有时候第三方库总会有些问题,fork 后修改再拉取也很麻烦,能不能直接修改它的本地版本然后直接用呢?当然是可以的,这里我们以 Windows 为例
cd C:\Users\(Username)\.cargo
// 如果你的依赖是这样引入的
[dependencies.tauri-plugin-highlander]
git = "https://github.com/ioneyed/tauri-plugin-highlander"
branch = "main"
// 那你可以在这里找到对应的本地源文件并修改
ls ./git/checkouts
> tauri-plugin-highlander-3a9ec0026e0d0fde
// 其他的引入方式根据你设置的来源找到本地源文件,比如这里有官方源和清华源等,进入对应目录修改文件即可
ls ./git/registry/src
> mirrors.ustc.edu.cn-61ef6e0cd06fb9b8
> mirrors.tuna.tsinghua.edu.cn-df7c3c540f42cdbd
日志功能
可参考 crate log4rs 及 Advanced logging in rust with log4rs: rotation, retention, patterns, multiple loggers
use log4rs::{
append::{
console::{ ConsoleAppender, Target },
rolling_file::{
RollingFileAppender,
policy::compound::{
CompoundPolicy,
roll::fixed_window::FixedWindowRoller,
// roll::delete::DeleteRoller,
trigger::size::SizeTrigger,
}
}
},
config::{ Appender, Config, Root },
encode::pattern::PatternEncoder,
filter::threshold::ThresholdFilter,
};
use log::{ debug, LevelFilter };
use path_absolutize::*;
use std::path::Path;
/**
* 初始化日志功能
* 更多格式详见 https://docs.rs/log4rs/latest/log4rs/encode/pattern/
*/
pub fn initialize_logger(log_file_path: &str) {
// 定义不同日志等级
let global_log_level = LevelFilter::Debug;
let stdout_log_level = LevelFilter::Debug;
let logfile_log_level = LevelFilter::Info;
let strong_level_log_level = LevelFilter::Warn;
// 定义日志输出格式
let log_pattern = "{d(%Y-%m-%d %H:%M:%S)} | {({l}):5.5} | {m}{n}";
// 定义分日志文件触发体积,目前设定为主日志达到 6Mb 后分离为独立文件
let trigger_size = byte_unit::n_mb_bytes!(6) as u64;
let trigger = SizeTrigger::new(trigger_size);
// 滚动式更新日志文件
let roller_pattern = "logs/log - {}.log"; // 指定滚动生成的日志文件路径及名称 1.log 2.log ...
let roller_count = 3; // 一共将存在的日志文件数量, 超过将会把最新的日志信息覆盖式写入到最早的日志文件中,如 3.log 到了 6 Mb,接下来的日志将输出到 1.log 中
let roller_base = 1; // 滚动生成日志文件的 index 即上述 roller_pattern 中 {} 值,递增,默认为 0
let roller = FixedWindowRoller::builder()
.base(roller_base)
.build(roller_pattern, roller_count).unwrap();
let strong_level_roller = FixedWindowRoller::builder()
.base(roller_base)
.build(roller_pattern, roller_count).unwrap();
// 达到 trigger_size 后直接删除旧日志文件,并创建空的新日志文件写入
// let delete_roller = DeleteRoller::new();
// 定义不同输出目标的文件更新策略
let log_file_compound_policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller));
let strong_level_compound_policy = CompoundPolicy::new(Box::new(trigger), Box::new(strong_level_roller));
// let compound_policy = CompoundPolicy::new(Box::new(trigger), Box::new(delete_roller));
// 输出日志到日志文件,若指定日志文件路径不存在则创建
let log_file = RollingFileAppender::builder()
.encoder(Box::new(PatternEncoder::new(log_pattern)))
.build(log_file_path, Box::new(log_file_compound_policy))
.unwrap();
// 输出 warn 和 error 等级到新文件
let absolute_path = Path::new(log_file_path).absolutize().unwrap();
let parent_path = absolute_path.parent().unwrap();
let strong_level_path = parent_path.join("strong_level.log").into_os_string().into_string().unwrap();
let strong_level_log_file = RollingFileAppender::builder()
.encoder(Box::new(PatternEncoder::new(log_pattern)))
.build(strong_level_path, Box::new(strong_level_compound_policy))
.unwrap();
// 输出日志到 stdout 即终端
let stdout = ConsoleAppender::builder()
.encoder(Box::new(PatternEncoder::new(log_pattern)))
.target(Target::Stdout)
.build();
// 设置 log4rs
let config = Config::builder()
.appender(
Appender::builder()
.filter(Box::new(ThresholdFilter::new(logfile_log_level)))
.build("log_file", Box::new(log_file)))
.appender(
Appender::builder()
.filter(Box::new(ThresholdFilter::new(strong_level_log_level)))
.build("strong_level", Box::new(strong_level_log_file))
)
.appender(
Appender::builder()
.filter(Box::new(ThresholdFilter::new(stdout_log_level)))
.build("stdout", Box::new(stdout)),
)
.build(
Root::builder()
.appender("stdout")
.appender("log_file")
.appender("strong_level")
.build(global_log_level))
.unwrap();
let _log_handler = log4rs::init_config(config).unwrap();
debug!("Programe logger initialize success");
}
时间
可参考 Crate: chrono
// cargo.toml
[dependencies]
...
chrono = "0.4"
// 获取当前时间
use chrono::{Utc, Local, DateTime};
let current_utc_time = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let current_local_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
// 加减时间, 以天为单位
let tommorrow_date = NaiveDate::parse_from_str(start_date, "%Y-%m-%d").unwrap() + Duration::days(1);
// 获取一个月天数
fn get_days_from_month(year: i32, month: u32) -> i64 {
NaiveDate::from_ymd(
match month {
12 => year + 1,
_ => year,
},
match month {
12 => 1,
_ => month + 1,
},
1
)
.signed_duration_since(NaiveDate::from_ymd(year, month, 1))
.num_days()
}
Tauri 相关
系统托盘
// tauri.config.json
{
"tauri": {
"systemTray": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
}
}
}
// 创建系统托盘
pub fn create_system_tray() -> SystemTray {
let quit = CustomMenuItem::new("quit".to_string(), "Quit Program"); // 创建 quit 选项,quit 为对应项目 id,Quit Program 为实际显示文本
let hide = CustomMenuItem::new("hide".to_string(), "Close to tray");// 创建 hide 选项,hide 为对应项目 id,Close to tray 为实际显示文本
let tray_menu = SystemTrayMenu::new()
.add_item(hide)
.add_native_item(SystemTrayMenuItem::Separator) // 添加项目间分隔线
.add_item(quit);
SystemTray::new().with_menu(tray_menu)
}
// 处理托盘事件
pub fn handle_system_tray_event(app: &AppHandle<Wry>, event: SystemTrayEvent) {
match event {
// 匹配项目双击事件
SystemTrayEvent::DoubleClick { position: _ , size: _, .. } => {
let window = app.get_window("main").unwrap();
window.unminimize().unwrap();
window.show().unwrap();
window.set_focus().unwrap();
},
// 匹配项目单击事件,根据 id 区分不同行为
SystemTrayEvent::MenuItemClick { id, ..} => {
match id.as_str() {
"quit" => {
app.get_window("main").unwrap().hide().unwrap();
app.emit_all("close", {}).unwrap();
debug!("System tray try to close");
}
"hide" => {
app.get_window("main").unwrap().hide().unwrap();
app.emit_all("hide", {}).unwrap();
debug!("System tray try to hide/show");
}
_ => {}
}
}
_ => {}
}
}
// 通过 command 国际化托盘文本
#[command]
pub fn change_system_tray_lang(lang: &str, app_handle: tauri::AppHandle) -> bool {
let window = app_handle.get_window("main").unwrap();
let hide_item = app_handle.tray_handle().get_item("hide");
let quit_item = app_handle.tray_handle().get_item("quit");
if &lang == &"zh-CN" {
let hide_title = if !window_visiable {"最小化至托盘"} else {"显示主界面"};
let quit_title = r"退出程序";
hide_item.set_title(format!("{}", hide_title)).unwrap();
quit_item.set_title(format!("{}", quit_title)).unwrap();
} else if &lang == &"en-US" {
let hide_title = if !window_visiable {"Close to tray"} else {"Show MainPage"};
let quit_title = r"Quit Program";
hide_item.set_title(format!("{}", hide_title)).unwrap();
quit_item.set_title(format!("{}", quit_title)).unwrap();
}
debug!("Change sysyem tray to lang {}", lang);
true
}
fn main() {
tauri::Builder::default()
.system_tray(create_system_tray())
.on_system_tray_event(handle_system_tray_event)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
去除顶部状态栏
可参考 Tauri: Window Customization
// tauri.config.json
{
"tauri": {
"windows": {
...
"decorations": false
}
}
}
// TitleBar.vue
<template>
<div data-tauri-drag-region class="titlebar">
<div class="titlebar-left" id="titlebar-left">
<div class="titlebar-icon" id="titlebar-icon">
<el-icon class="app-icon">
<AppIcon />
</el-icon>
</div>
<div class="titlebar-title" id="titlebar-title">
{{t('general.AppTitle')}}
</div>
</div>
<div class="titlebar-right" id="titlebar-right">
<div class="titlebar-button" id="titlebar-minimize">
<el-icon><SemiSelect /></el-icon>
</div>
<div class="titlebar-button" id="titlebar-maximize">
<el-icon><BorderOutlined /></el-icon>
</div>
<div class="titlebar-button" id="titlebar-close">
<el-icon><CloseBold /></el-icon>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import { appWindow } from '@tauri-apps/api/window';
import { AppstoreOutlined, BorderOutlined } from '@vicons/antd';
import { SemiSelect, FullScreen, CloseBold } from '@element-plus/icons-vue';
import AppIcon from '~icons/icons/favicon';
onMounted(() => {
(document.getElementById('titlebar-minimize') as HTMLElement).addEventListener('click', () => appWindow.minimize());
(document.getElementById('titlebar-maximize') as HTMLElement).addEventListener('click', () => appWindow.toggleMaximize());
(document.getElementById('titlebar-close') as HTMLElement).addEventListener('click', () => appWindow.close());
})
</script>
<style lang="less" scoped>
@import "../assets/style/theme/default-vars.less";
.titlebar {
height: 30px;
color: var(--el-text-color-regular);
background-color: var(--el-bg-color);
user-select: none;
display: flex;
flex-direction: row;
justify-content: space-between;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
.titlebar-left {
display: flex;
flex-direction: row;
.titlebar-icon {
width: 30px;
height: 30px;
padding-top: 5px;
padding-left: 5px;
.app-icon {
font-size: 20px;
}
}
.titlebar-title {
padding-top: 6px;
}
}
.titlebar-right {
display: flex;
flex-direction: row;
.titlebar-button {
display: inline-flex;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
}
.titlebar-button:hover {
background: var(--el-border-color);
}
#titlebar-close:hover {
background: red;
}
}
}
</style>
添加窗口阴影
可参考 window-shadows,注意:暂不支持 Linux
use window_shadows::set_shadow;
pub fn initialize_window_shadow(window: &TauriWindow, is_shadow_enable: bool) {
if let Err(e) = set_shadow(window, is_shadow_enable) {
error!("Failed to add native window shadow, errMsg: {:?}", e);
}
}
fn main() {
tauri::Builder::default()
.setup(|app| {
let main_window = app.get_window("main").unwrap();
initialize_window_shadow(&main_window, true);
Ok(())
})
.invoke_handler(tauri::generate_handler![
add_todo,
delete_todo,
start_heart_rate
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
限制多实例运行(暂不可用)
可参考 Tauri Plugin Highlander 或 Single Instance
⚠️ tauri-plugin-highlander 目前无法通过 Github Actions 下 Ubuntu 和 MacOS 环境的编译,已提交相关 pr 修复,此处使用修复后的分支
❌ single_instance 目前在纯 rust 环境下表现良好,在 tauri 环境下观察到所有实例均为 single,暂未查明原因
// tauri-plugin-highlander
// cargo.toml
[dependencies.tauri-plugin-highlander]
git = "https://github.com/Hellager/tauri-plugin-highlander"
branch = "fix-platform-build-error"
// main.rs
use tauri_plugin_highlander::*;
use tauri::Wry;
pub fn initialize_plugin_highlander(event_name: &str) -> Highlander<Wry> {
HighlanderBuilder::default().event(event_name.to_string()).build()
}
fn main() {
let plugin_highlander = initialize_plugin_highlander("another_instance");
tauri::Builder::default()
.plugin(plugin_highlander)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// single-instance
// cargo.toml
single-instance = "0.3"
// xxx.rs
pub fn check_instance(name: &str) {
let instance = SingleInstance::new(name).unwrap();
debug!("Check instance {} whether single {}", name, instance.is_single());
if !instance.is_single() {
debug!("{} is not single", name);
std::process::exit(0);
} else {
debug!("{} is single", name);
}
}
拦截 app 请求
pub fn handle_app_event(_app_handle: &AppHandle<Wry>, event: RunEvent) {
match event {
RunEvent::Exit => {}
RunEvent::ExitRequested { .. } => {}
RunEvent::WindowEvent { label, event, .. } => {
match event {
WindowEvent::CloseRequested { api, .. } => {
// 拦截 main 窗口发起的关闭 event, 并发送 close 事件至前端,交给前端处理
if label == "main" {
let _ = _app_handle.get_window("main").unwrap().hide();
_app_handle.emit_all("close", {}).unwrap();
debug!("Rust try to close");
api.prevent_close()
}
}
_ => {}
}
}
RunEvent::Ready => {}
RunEvent::Resumed => {}
RunEvent::MainEventsCleared => {}
_ => {}
}
}
fn main() {
let _app = tauri::Builder::default()
.build(tauri::generate_context!())
.expect("error while running tauri application");
_app.run(handle_app_event)
}
加载页
// tauri.config.json
{
"tauri": {
"windows": [
{...} // main window configuration
{
"width": 500,
"height": 300,
"center": true,
"decorations": false,
"url": "splashscreen.html",
"label": "splashscreen",
"title": "Loading",
"transparent": true
}
]
}
}
// splashscreen.html 示例
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body {
background-color:transparent;
}
canvas {
display: block;
left: 50%;
margin: -125px 0 0 -125px;
position: absolute;
top: 50%;
}
.container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
#content {
margin-top: 280px;
font-size: 2vw;
font-weight: bold;
color: #FFFFFF;
}
</style>
</head>
<body>
<div class="container">
<canvas id="canvas" width="250" height="250"></canvas>
<span id="content">Loading...</span>
</div>
<script>
// refer Organic Circle from https://uxplanet.org/using-loading-animation-on-websites-and-apps-examples-and-snippets-to-use-cab0097be9f1
var canvasLoader = function(){
var self = this;
window.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(a){window.setTimeout(a,1E3/60)}}();
self.init = function(){
self.canvas = document.getElementById('canvas');
self.ctx = self.canvas.getContext('2d');
self.ctx.lineWidth = .5;
self.ctx.strokeStyle = 'rgba(255,255,255,.75)';
self.count = 75;
self.rotation = 270*(Math.PI/180);
self.speed = 6;
self.canvasLoop();
};
self.updateLoader = function(){
self.rotation += self.speed/100;
};
self.renderLoader = function(){
self.ctx.save();
self.ctx.globalCompositeOperation = 'source-over';
self.ctx.translate(125, 125);
self.ctx.rotate(self.rotation);
var i = self.count;
while(i--){
self.ctx.beginPath();
self.ctx.arc(0, 0, i+(Math.random()*35), Math.random(), Math.PI/3+(Math.random()/12), false);
self.ctx.stroke();
}
self.ctx.restore();
};
self.canvasLoop = function(){
requestAnimFrame(self.canvasLoop, self.canvas);
self.ctx.globalCompositeOperation = 'destination-out';
self.ctx.fillStyle = 'rgba(255,255,255,.03)';
self.ctx.fillRect(0,0,250,250);
self.updateLoader();
self.renderLoader();
};
};
var loader = new canvasLoader();
loader.init();
</script>
</body>
</html>
手动退出程序
⚠️ 两种退出方式都不会触发 app 的 close 事件,而是直接退出
use std::process;
process::exit(0)
<script lang="ts" setup>
import { appWindow } from '@tauri-apps/api/window';
appWindow.close();
</scriptscript>
博客版:
Medium 版:
New Acquaintance With Tauri Chapter 5: Infrastructure of App