enpitsulin

使用Tauri构建桌面端应用程序——以TodoMVC为例(上)

Rust 学的一头雾水?错,是太难了根本学不会,直接上实践就完事了。就用学习一个框架最经典的实战项目 TodoMVC,我们实现一个 rust+sqlite 做后端 、react 做前端的跨平台桌面端 app

创建 Tauri 项目

虽然根据官方文档新建一个项目很简单。

不过我是用 pnpm 做包管理,pnpm 创建项目运行如下

pnpm create tauri-app

我们选择使用从create-vite 然后使用 react-ts 模板

创建项目|1481x785

然后等待 cli 安装完依赖,用 VSCode 打开项目,这里建议你安装rust-analyzer不过我估计学习 rust 应该早都推荐安装了,然后我们的项目目录就如下

目录|311x225

存放前端项目内容的 src 和 rust 后端的 src-tauri,对于 web 界面的开发就很正常开发 react 一样,但是对于和编写 rust 后端就不同于 electron 了毕竟 rust 和 nodejs 完全不一样

构建页面

首先我们直接使用 TodoMVC 这个项目提供的 css,安装todomvc-app-css

pnpm add todomvc-app-css

然后在入口文件引入,并把原先引入的样式文件删掉

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
+ import 'todomvc-app-css/index.css'
- import './index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

然后新建 components 目录并创建 TodoItem 组件

const TodoItem = () => {
  return (
    <li>
      <div className="view">
        <input type="checkbox" className="toggle" autoFocus />
        <label>some todo item</label>
        <button className="destroy"></button>
      </div>
    </li>
  )
}
export default TodoItem

以及 TodoList 组件

import TodoItem from './TodoItem'
const TodoList = () => {
  return (
    <>
      <header className="header">
        <h1>todos</h1>
        <input type="text" className="new-todo" placeholder="What needs to be done?" />
      </header>
      <section className="main">
        <input type="checkbox" className="toggle-all" />
        <label htmlFor="togle-all"></label>
        <ul className="todo-list">
          <TodoItem />
        </ul>
      </section>
      <footer className="footer">
        <span className="todo-count">
          <strong>1</strong> items left
        </span>
        <ul className="filters">
          <li>
            <a>All</a>
          </li>
          <li>
            <a>Active</a>
          </li>
          <li>
            <a>Completed</a>
          </li>
        </ul>
      </footer>
    </>
  )
}
export default TodoList

然后在 App.tsx 中把原先模板中的代码删除然后引入 TodoList 并展示出来。

import TodoList from './component/TodoList'

function App() {
  return (
    <div className="todoapp">
      <TodoList />
    </div>
  )
}

export default App

启动 Tauri

然后启动 tauri,就可以看到效果了

pnpm tauri dev

效果|1002x789

不过这部分仅仅只是显示简单的 html 结构以及相应的 css 样式没有任何功能

开发后端

实现 web 界面功能先放到一边,先来考虑下 rust 后端操作,先了解下 tauri 怎么通信的

根据官方文档我们可以通过 TauriAPI 包或者设置tauri.conf.json > build > withGlobalTauri为 true 来将 invoke 挂载到 window.__TAURI__ 对象上,比较建议开启withGlobalTauri让一会的调试更简单,虽然 tauri 官方有 test 但是我觉得直接在控制台测试更简单

然后我们就可以使用 invoke 调用 rust 后端提供的方法了

以下的操作均为 src-tauri/ 目录下

使用 sqlite

首先添加 rusqlite 依赖来获得操作 sqlite 的能力

[dependencies]
# ...
+ rusqlite = { version = "0.27.0", features = ["bundled"] }

对 sqlite 数据库的操作

参考 rusqlite的用法,我们创建一个方法来创建数据库连接。

fn connect() -> Result<()>{
    let db_path = "db.sqlite";
    let db = Connection::open(&db_path)?;
    println!("{}", db.is_autocommit());
    Ok(())
}

然后我们可以实现更多的方法来对数据库增删改查,但写太多方法每次都要创建数据库连接然后断开,比较麻烦,于是我们可以实现一个结构体TodoApp来封装常用的方法

设计 TodoApp 类

设计表结构

首先我们设计一下数据库表结构

Todo 表比较简单的结构,建表语句:

CREATE TABLE IF NOT EXISTS Todo (
    id          varchar(64)     PRIMARY KEY,
    label       text            NOT NULL,
    done        numeric         DEFAULT 0,
    is_delete   numeric         DEFAULT 0
)

todo 模块

然后我们新建一个todo.rs模块,同时建立一个 Todo 结构体作为数据库行的类型供未来使用,这里都用 pub 因为我们可能在 main 中使用的时候访问这些属性

pub struct Todo {
    pub id: String,
    pub label: String,
    pub done: bool,
    pub is_delete: bool,
}

以及 TodoApp 结构体,不过暂时未实现内部的方法

pub struct TodoApp {
    pub conn: Connection,
}
impl TodoApp {
    //To be implement
}

然后就是抽象 CURD 成几个成员方法,由于 rust 不存在 new 这个关键词 我们构造一个类对象一般约定存在一个pub fn new()来对相应的类进行构造,其实所谓的构造就是返回一个存在 impl 的结构体。

于是添加以下实现

impl TodoApp{
    pub fn new()->Result<TodoApp>{
        let db_path = "db.sqlite";
        let conn = Connection::open(db_path)?;
        conn.execute(
            "CREATE TABLE IF NOT EXISTS Todo (
                id          varchar(64)     PRIMARY KEY,
                label       text            NOT NULL,
                done        numeric         DEFAULT 0,
                is_delete   numeric         DEFAULT 0
            )",
            [],
        )?;
        Ok(TodoApp { conn })
    }
}

使用Result枚举类型是因为Connection::open返回的也是Result,我们希望使用?通过错误传播来简化错误流程处理(虽然这个方法也没有 Err,所以就只是为了简化流程罢了)

然后我们就可以通过TodoApp::new().unwrap()来构造一个 TodoApp 类了,使用 unwrap()拆解枚举类型中的 Ok 即我们返回的 TodoApp

实现 TodoApp 各种方法

既然已经能够构造类了,那么我们希望能对 sqlite 进行 CURD 等操作,当然需要相应的方法,如 get_todosget_todo(id)new_todo(todo)update_todo(todo),没有删除的方法因为设计表就已经设计了 is_delete 字段,决定我们的删除还是软删除,硬删除暂时不(lān de)实现

查询所有的 todo

使用到 Connection.prepare()方法,这个方法返回的Statement的几个方法query_mapexecute等,可以接收参数然后将参数传递调用prepare()时的语句中进行查询并返回。

这里我们使用query_map方法来得到一个迭代器,通过遍历迭代器我们获得了Vec<Todo>就是 Todo 对象的数组然后将其通过Result枚举包装然后返回

注意带有泛型但泛型不受参数控制的方法如 line 6 这个row.get方法传入泛型参数是以row.get::<I, T>()方式调用的。

因为 sqlite 中没有 boolean 类型所以我们使用 numeric 通过 1 或 0 来标识 true 或 false,使用对于这两个字段都需要记得处理一下

impl TodoApp {
#   ...
    pub fn get_todos(&self) -> Result<Vec<Todo>> {
        let mut stmt = self.conn.prepare("SELECT * FROM Todo").unwrap();
        let todos_iter = stmt.query_map([], |row| {
            let done = row.get::<usize, i32>(2).unwrap() == 1;
            let is_delete = row.get::<usize, i32>(3).unwrap() == 1;

            Ok(Todo {
                id: row.get(0)?,
                label: row.get(1)?,
                done,
                is_delete,
            })
        })?;
        let mut todos: Vec<Todo> = Vec::new();

        for todo in todos_iter {
            todos.push(todo?);
        }

        Ok(todos)
    }
}

于是我们可以获得 Sqlite 中的数据了,但是仍然需要提供 command 来供前端调用,我们回到 main.ts,先引入模块并导入 Todo 和 TodoApp

#![cfg_attr(
    all(not(debug_assertions), target_os = "w&mut &mut indows"),
    windows_subsystem = "windows"
)]
+ mod todo;
+ use todo::{Todo, TodoApp};

fn main() {
    tauri::Builder::default()
+        .invoke_handler(tauri::generate_handler![
+            get_todos,
+        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

+ #[tauri::command]
+ fn get_todos() -> Vec<Todo> {
+     todo!()
+ }

封装 tauri 状态

我们需要使用 rust 标准库中的 Mutex 互斥锁以及 tauri 的 state 来让我们封装的 TodoApp 对象能够跨进程调用,首先新建一个AppState结构体做状态管理。

然后通过tauri::Buildermanage方法来管理这个状态。然后我们就可以封装command来使用这个对象的的方法来获取数据了。

#![cfg_attr(
    all(not(debug_assertions), target_os = "windows"),
    windows_subsystem = "windows"
)]
mod todo;
use todo::{Todo, TodoApp};

+ struct AppState {
+     app: Mutex<TodoApp>,
+ }

fn main() {
+   let app = TodoApp::new().unwrap();
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_todos,
        ])
+       .manage(AppState {
+           app: Mutex::from(app),
+       })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

#[tauri::command]
fn get_todos() -> Vec<Todo> {
+    let app = state.app.lock().unwrap();
+    let todos = app.get_todos().unwrap();
+    todos
}

返回数据序列化

写好 command 之后发现注解这里报错了。

Error|658x358

这里是因为我们返回的Vec<Todo>不是可以序列化的类型不能通过指令返回到前端,回到 todo.rs,我们增加注解给结构体增加序列化的功能。

+ use serde::{Serialize};

+ #[derive(Serialize)]
pub struct Todo {
    pub id: String,
    pub label: String,
    pub done: bool,
    pub is_delete: bool,
}

然后我们在界面上右键检查打开控制台然后输入await __TAURI__.invoke("get_todos")应该就能看到返回的空数组了

get_todos|789x797

invoke 参数反序列化

其实需要序列化和反序列化的原因就和前后端分离的 web 应用一样,在传输层使用的是 json 格式,但应用需要真正的对象,所以需要通过注解给对象添加 Serialize 和 Deserialize 接口

同时 invoke 方法也是可以接受第二个参数作为对 commond 调用的参数的,但是参数也需要具备从 json 格式反序列化数据的能力,于是增加注解

- use serde::{Deserialize};
+ use serde::{Serialize, Deserialize};

- #[derive(Serialize)]
+ #[derive(Serialize, Deserialize)]
pub struct Todo {
    pub id: String,
    pub label: String,
    pub done: bool,
    pub is_delete: bool,
}

完善 CURD

除了使用Connection::prepare返回的Statement中的方法,我们也可以从Connection直接executeSQL 语句,比如这个新增 todo,从 invoke 中获取 todo 参数并反序列化成Todo对象,然后结构获得 id 和 label 然后传递给 SQL 语句的参数完成 INSERT

pub fn new_todo(&self, todo: Todo) -> bool {
    let Todo { id, label, .. } = todo;
    match self
        .conn
        .execute("INSERT INTO Todo (id, label) VALUES (?, ?)", [id, label])
    {
        Ok(insert) => {
            println!("{} row inserted", insert);
            true
        }
        Err(err) => {
            println!("some error: {}", err);
            false
        }
    }
}

同理还有update_todoget_todo,这里就不多列代码了,就给一个函数签名吧, 这里返回值愿意通过 Result 封装或者不封装其实应该问题都不大,看个人喜好了。

pub fn update_todo(&self, todo: Todo) -> bool {
  // more code
}
pub fn get_todo(&self, id: String) -> Result<Todo> {
  // also more code
}

同理也需要增加相应的指令

#[tauri::command]
fn new_todo(todo: Todo) -> bool {
    let app = TodoApp::new().unwrap();
    let result = app.new_todo(todo);
    app.conn.close();
    result
}

#[tauri::command]
fn update_todo(todo: Todo) -> bool {
    //to be implemented
}

#[tauri::command]
fn toggle_done(id: String) -> bool {
    //to be implemented
}

以及别忘了在 generate_handler 中增加

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_todos,
+            new_todo,
+            toggle_done,
+            update_todo
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

至此我们就基本完成了 TodoMVC 的后端,接下来在下篇中使用 react + jotai + 一些包 来完成这个应用的前端以及与 rust 后端的通信这部分就很水了,基本就是基础的 react

下篇链接

使用 Tauri 构建桌面端应用程序——以 TodoMVC 为例(下)

使用Tauri构建桌面端应用程序——以TodoMVC为例(上)

https://enpitsulin.xyz/blog/todo-mvc-app-with-tauri-1

Author

enpitsulin

Posted on

Updated on

Licensed under