Tauri Stateで状態管理する

このシリーズは、Tauriに関する実装知識について投稿しています。
前回は、フロントエンドとバックエンドの処理を繋ぐためのコマンド関数(Command)について紹介しました。

コマンド関数を使うと、フロントエンド側からリクエストして、バックエンド側に処理を依頼できる。
処理の結果をレスポンスとして受け取れることがわかりました。

sequenceDiagram
    participant Front as Front: レンダラープロセス<br>(JS or TS)
    participant Back as Back: メインプロセス<br>(Rust)

    Front->>+Back: Request
    Note left of Front: tauriAPI<br>invoke(cmd, args): Promise<T>

    Note right of Back: コマンド関数<br>#35;[tauri::command]<br>fn my_command(args) -> Result<T, E>
    Back->>-Front: Response

    Note left of Front: .then(t=>{...})<br>.catch(e=>{...})<br>(or await)

バックエンド側に何か状態をもっていて、フロントエンドからリクエストして取得や操作をするようなケースでは、メインプロセス側で状態を表す変数を管理したくなるかと思います。

今回の記事では、
そのために用意されているTauriのStateという機能について紹介します。

Stateとは

バックエンド側に何か状態を保持したいときのために、TauriではStateという仕組みが用意されています。
Stateはアプリケーションインスタンスに、manageメソッドを使って特定の変数を保持させておく機能です。

graph RL

subgraph フロントエンド
    Window[ブラウザ Window]
end

subgraph バックエンド
    subgraph Manager
        State1("ステート変数<br>(manageメソッドで登録しておく)")
    end
    Command[コマンド関数]
    Setup[Setupハンドラ]
    Events[Windowイベントハンドラ]
end

State1 -.- |引数バインドでアクセス可能| Command
State1 -.- |App#stateでアクセス可能| Setup
State1 -.- |Window#stateでアクセス可能| Events
Window --> |リクエスト<br>invoke| Command
Command --> |レスポンス<br>Result<T,E>| Window

保持しているステート変数を取り出すには、tauri::Managerトレイトを備えているインスタンスから、stateメソッドを使って取り出すことができます。
また、コマンド関数からも取得が可能です。

Rustはグローバル変数をできるだけ避けることを推奨しているので、Tauriでもこのようにアプリケーションインスタンスに変数を登録しておいて、各ハンドラや処理の際にアクセスできるような手段を用意しています。

コマンド関数でStateを取得する

コマンド関数では、State型の引数を定義するだけで、管理されているステート変数が自動バインドされます。

下記の例は、Personという型の変数をステート変数としてすでに登録している場合に、
person: State<'_, Person> と引数に追記すると扱うことができます。

#[tauri::command]
fn my_command(
    person: State<'_, Person>, // ← State<...> の引数があると、自動バインドされる。
) -> Result<(), String> {
    println!("{:?}", person);
    Ok(())
}

State<>は、Stateガードと呼ばれるものです。
Derefトレイトと、Dropトレイトを実装しているスマートポインタです。

State変数の登録 (manageメソッド)

変数を用意して実際にManagerにState変数を登録する方法をみてみましょう。

main.rs

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            my_command,
        ])
        .setup(|app| {
            // setupハンドラに、初期化処理が書けます。

            // App#manageメソッドでステート変数を管理するように登録できる。
            let state = "hello".to_owned();
            app.manage(state);

            // 開発時だけdevtoolsを表示する。
            #[cfg(debug_assertions)]
            app.get_window("main").unwrap().open_devtools();

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("failed to run app");
}

ステート変数を登録する、manageメソッドが使えるのは、tauri::Builder か、tauri::Apptauri::AppHandletauri::Window などのtauri::Managerトレイトを備えているインスタンスです。

アプリケーション起動時のsetupハンドラ内で、ステート変数を設定することが多いと思います。

let state = "hello".to_owned(); app.manage(state);

上記の例は、"hello” というString変数を、ステート変数として登録しています。

ステート変数は型ユニーク

前述の例では、Stringを直接、ステート変数として登録しましたが、
実はステート変数は型情報でユニークにしか登録ができません。
(同じ型の変数を二つ以上登録して保持することはできない)

もし、同じString型を、もう一つ登録しようとした場合。
manageメソッドをコールした時点でpanicが発生してしまいます。

同じ型を複数登録したい場合を考慮して、ステート変数にする型はtuple-structでラップして扱うと良いです。
例えば、String型のステート変数を2つ用意したい場合は、下記のようにそれぞれユニークな型として定義して扱います。

struct StringState1(String);
struct StringState2(String);

もしくは、一つの構造体にまとめてしまうのも良いでしょう。

// ステート変数ごとにユニークになるように型定義
struct StringState1(String);
struct StringState2(String);

// または、一つにまとめた構造体定義にする
struct MyState {
    text1: String,
    text2: String,
}

// ~ 省略 ~

// tuple-structを使って区別して登録
let state1 = StringState1("hello".to_owned());
let state2 = StringState2("world".to_owned());
app.manage(state1);
app.manage(state2);

// 一つのstructにまとめて登録
let my_state = MyState {
    text1: "hello".to_owned(),
    text2: "world".to_owned(),
};
app.manage(my_state);

ミュータブルなステート変数

ステート変数に登録できるのは、イミュータブルな変数のみです。
ただそれだけでは、変化する状態を管理することもできないので役に立ちません。
ミュータブルな変数を扱いたい場合は、Mutexでラップして扱うと良いでしょう。

使う際にはlockして利用することになりますが、
Mutex自体はイミュータブルな扱いなので、内部の変数はlockすればが変更が可能というわけです。

あまり多くの変数をMutexで管理すると、利用時にデッドロックする危険性も高まると思うので、
関連性のある機能はロジックにまとめておくのが良いでしょう。

ステート変数を使ったサンプル

最後に、ステート変数を使って
Personリストをバックエンド側で管理したアプリを用意してみました。

フロントエンド側は、追加とリセットができるもので、
その都度最新のリストをバックエンドから取得しています。

フロントエンド側実装

PersonHook.tsx

import {useCallback, useEffect, useState} from 'react';

// tauriのAPIを使ってアクセスする。
import {tauri} from '@tauri-apps/api';

/**
 * バックエンド側が返す型と合わせる。
 */
export type Person = {
    uuid: string,
    name: string,
    age: number,
}

/**
 * Backend側のコマンド関数、invokeする処理をラップしているだけ。
 */
const personList = async (): Promise<Person[]> => {
    // コマンド関数に渡す引数は、invokeメソッドの第二引数にマップの形で指定する。
    return await tauri.invoke("person_list");
}

const personAdd = async () => {
    return await tauri.invoke("person_add");
}

const personClean = async () => {
    return await tauri.invoke("person_clean");
}

/**
 * personリストを扱うためのカスタムフック関数
 */
export const usePersonList = (): [Person[], (() => Promise<void>), (() => Promise<void>)] => {
    const [list, setList] = useState<Person[]>([]);

    const handleGet = useCallback(async () => {
        setList(await personList());
    }, []);

    // 初回取得
    useEffect(() => {
        handleGet().catch(console.log);
    }, [handleGet]);

    const handleAdd = useCallback(async () => {
        await personAdd();
        await handleGet();
    }, [handleGet]);

    const handleClean = useCallback(async () => {
        await personClean();
        await handleGet();
    }, [handleGet]);

    return [list, handleAdd, handleClean];
}

App.tsx

import {Box, Button, Container, Stack} from "@mui/material";
import {usePersonList} from "./PersonHook";

const App: FC = () => {
    const [personList, handleAdd, handleClean] = usePersonList();

    return (
        <Container>
            <Stack direction="row" justifyContent="space-evenly" sx={{p: "1rem"}}>
                <Button variant="contained" onClick={handleAdd}>Add Person</Button>
                <Button variant="contained" onClick={handleClean}>Clean</Button>
            </Stack>

            <Stack spacing={1}>
                {personList.map((person, i) => {
                    return <Box key={person.uuid}>{i + 1}: {person.name} ({person.age})</Box>;
                })}
            </Stack>
        </Container>
    );
}

バックエンド側実装

person.rs

use std::sync::Mutex;

use rand::Rng;
use serde::Serialize;

// ランダム生成に使う名前
const FIRST_NAMES: [&str; 16] = [
    "Nicola",
    "Gladys",
    "Kali",
    "Chardonnay",
    "Regan",
    "Dollie",
    "Leila",
    "Alexis",
    "Malika",
    "Elisha",
    "Juniper",
    "Aurelia",
    "Danika",
    "Mirza",
    "Harriett",
    "Gilbert",
];

// ランダム生成に使う名前
const LAST_NAMES: [&str; 16] = [
    "Dunkley",
    "Reilly",
    "Wormald",
    "Nairn",
    "Moyer",
    "Braun",
    "Flynn",
    "Vinson",
    "Hawes",
    "Haas",
    "Hill",
    "Kerr",
    "Gill",
    "Mays",
    "Gordon",
    "Sharma",
];

/// フロンドエンド側に返すための型
/// serde::Serialize でJSON化できる構造体にしておく。
#[derive(Debug, Serialize, Clone)]
pub struct Person {
    uuid: String,
    name: String,
    age: usize,
}

/// Person型を管理するマネージャー
/// 今回のサンプルでは、ステート変数として管理するものになります。
pub struct PersonManager {
    list: Mutex<Vec<Person>>,
}

impl PersonManager {
    pub fn new(list: Vec<Person>) -> Self {
        Self {
            list: Mutex::new(list)
        }
    }

    /// 新しいPersonを生成して追加する。
    pub fn add_new_person(&self) {
        let mut list = self.list.lock().unwrap();

        let new_person = Self::generate_person();
        list.push(new_person);
    }

    /// 現在のPersonリストを取得する。
    pub fn person_list(&self) -> Vec<Person> {
        let list = self.list.lock().unwrap();
        list.clone()
    }

    /// 管理しているリストをクリアします。
    pub fn clean(&self) {
        let mut list = self.list.lock().unwrap();
        list.clear();
    }

    /// Personのランダム生成関数
    fn generate_person() -> Person {
        let mut rng = rand::thread_rng();

        // NAMESは、&str配列
        let i = rng.gen_range(0..FIRST_NAMES.len());
        let j = rng.gen_range(0..LAST_NAMES.len());

        let uuid = uuid::Uuid::new_v4().to_string();
        let name = format!("{} {}", FIRST_NAMES[i], LAST_NAMES[j]);
        let age: usize = rng.gen_range(0..=100);

        Person { uuid, name, age }
    }
}

pub mod commands {
    use tauri::State;

    use super::*;

    #[tauri::command]
    pub fn person_list(
        person_manager: State<'_, PersonManager>,
    ) -> Result<Vec<Person>, String> {
        let person_list = person_manager.person_list();
        Ok(person_list)
    }

    #[tauri::command]
    pub fn person_add(
        person_manager: State<'_, PersonManager>,
    ) -> Result<(), String> {
        person_manager.add_new_person();
        Ok(())
    }

    #[tauri::command]
    pub fn person_clean(
        person_manager: State<'_, PersonManager>,
    ) -> Result<(), String> {
        person_manager.clean();
        Ok(())
    }
}

main.rs

use tauri::Manager;

use person::commands;
use person::PersonManager;

pub mod person;

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            commands::person_list,
            commands::person_add,
            commands::person_clean,
        ])
        .setup(|app| {
            // setupハンドラに、初期化処理が書けます。

            // App#manageメソッドでステート変数として管理するように登録できる。
            let person_manager = PersonManager::new(Vec::new());
            app.manage(person_manager);

            // 開発時だけdevtoolsを表示する。
            #[cfg(debug_assertions)]
            app.get_window("main").unwrap().open_devtools();

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("failed to run app");
}

実行結果

addボタンを押すと、ランダムで生成されたPersonがステート変数内で増えています。

F5で更新した後でも、バックエンドで保持しているリストから再取得できているのが確認できてるかと思います。

まとめ

  • Tauriにはステート変数という仕組みがある。
  • mangeメソッドで、ステート変数として登録することが可能。
  • ステート変数には型ユニークでしか登録できないので、structか、tuple-structにして登録しよう。
  • 変更が必要が変数(ミュータブル)は、Mutexでラップすること。
  • stateメソッドで取得するか、コマンド関数に引数に書くと自動バインドされて使える。。
  • ステート変数は、Stateガードを通してアクセスする。
タイトルとURLをコピーしました