enpitsulin

enpitsulin

这个人很懒,没有留下什么🤡
twitter
github
bilibili
nintendo switch
mastodon

使用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 = "windows"),
    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 為例(下)

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。