從React的視角談談Rust和GTK

最近我嘗試了多種框架,想要製作出既易用又容易安裝的應用程序,但是都以失敗告終;最後我決定轉向Rust和GTK,開始擁抱原生軟件開發。

雖說以前我也短暫嘗試過GTK,但它對我來說還是很陌生的。在此之前,我在用戶界面上的大部分經驗都來自於React應用程序的構建。從React到GTK的過渡帶來了一些挑戰,其中多數是小部件原理上的差異造成的。用Rust寫GTK是尤其困難的事情,因爲Rust強制執行一些額外的規則來防止內存管理錯誤,並避免在線程上下文中執行不安全的操作。

在本文中,我將主要討論如何將React的理念應用到GTK中,並重點介紹一些使GTK符合Rust規則所必需的技巧。Rust制訂了一些不好對付的強制規則,這些規則對於大多數開發人員來說都是陌生的;規則主要涉及值的共享方式,但在可變性方面也有嚴格的限制。我將在本文中遇到這些場景時指出它們。

本文中的所有示例均來自FitnessTrax(https://github.com/luminescent-dreams/fitnesstrax/),這是一款遵循隱私優先原則的健身追蹤應用程序。用戶可以在他們的PC上的一處存儲空間內收集健身和生物識別數據,而不必依賴那些可能無法持續保護用戶數據的公司。

關於這款應用程序的外觀我要說句抱歉,因爲0.4版(https://savanni.luminescent-dreams.com/2020/01/03/weekly-ramblings/)發佈的時候,我還沒去花時間瞭解GTK是如何處理樣式的。我保證會盡快改進用戶界面。

框架哲學上的一些差異

Conrod(https://github.com/PistonDevelopers/conrod)是針對Rust的一個圖形工具包,它試着將函數式響應編程(https://en.wikipedia.org/wiki/Functional_reactive_programming)技術應用到了圖形編程上;它的開發者它描述了兩種有着明顯區別的圖形組件管理模式(https://docs.rs/conrod/0.61.1/conrod/guide/chapter_1/index.html#immediate-mode)。在大多數原生圖形編程採用的通用模式,亦即“保留模式(retained mode)”下,開發人員將創建一個個屏幕組件,然後在它們的整個生命週期內一次次更新。在“立即模式(immediate mode)”下,組件將具有一個繪製(draw)方法,其中組件會立即實例化自身的所有子級。然後,框架將對比這棵樹與上一棵樹,來判斷該如何更新屏幕。

React完全運行在即時模式下,而GTK完全運行在保留模式下。在Web開發行業中流行的數據可視化庫D3(https://d3js.org/)也可以運行在保留模式下。2018年,我寫了一篇關於React和D3之間對接的文章(https://www.cloudcity.io/blog/2018/08/07/breaking-d3s-deathgrip-on-the-dom-bringing-old-code-back-to-life-in-a-react-era/)。

與Redux或Apollo-GraphQL(https://www.apollographql.com/)搭配的React實現了函數式響應編程(FRP)的一些理念,讓它可以自動處理傳播到組件的數據更改。我入門FRP時看的是Elise Huard寫的一本書“Haskell中的遊戲編程”(https://leanpub.com/gameinhaskell)。時至今日這本書可能已經過時了,但在Haskell中特定的某個FRP庫的背景下,它確實很好地介紹了這種理念。不幸的是,FRP尚未在React之外得到廣泛採用。雖說至少有一個可用於Rust的FRP庫,但在撰寫本文時,對於我來說採用它還爲時過早。因此,憑藉一些創造力和我在React領域的經驗,我設計了一些類似於FRP範式的機制。

一些術語的註釋:

  • 小部件(widget)是一個GTK對象,代表屏幕上的某些內容。它可以是一個窗口、按鈕、標籤或一個佈局容器。GTK小部件只能將其他GTK小部件作爲自身的子級。
  • 組件是屏幕上一個部分的任意邏輯抽象。在簡單的情況下,它會是一個從某個函數返回的GTK小部件。在更復雜的情況下,它可能是包含一個或多個小部件的結構。組件不一定必須傳遞給GTK函數。結構組件始終提供一個’widget’字段,其代表這個組件的根小部件。

不可變值的顯示

所有組件中最簡單的,就像React組件一樣是一小組小部件,這些小部件創建後就永遠不會更新。這可以簡單地實現爲返回一個GTK小部件的函數。

pub fn date_c(date: &chrono::Date<chrono_tz::Tz>) -> gtk::Label {
    gtk::Label::new(Some(&format!("{}", date.format("%B %e, %Y"))))
}

當組件實際上是一個很少或甚至從不更新的可視組件時,這種模式就是可行的。在我的應用程序中,日期標籤是更大一塊顯示內容的子組件,因此是永遠不變的東西。

具有內部小部件狀態的組件

具有內部小部件狀態的組件肯定要複雜得多,但仍然可以實現爲一個返回GTK小部件的函數。調用方可以直接從返回的GTK小部件中讀取數據;在調用方提供一個回調,並且組件代碼寫明瞭何時調用回調時,這種模式可以說是最有效的。

我有一個會驗證文本的輸入字段。這是一個常規的gtk::Entry(https://gtk-rs.org/docs/gtk/struct.Entry.html),但是接口抽象了’render’、'parse’和’on_update’函數背後的文本處理過程。

pub fn validated_text_entry_c<A: 'static + Clone>(
    value: A,
    render: Box<dyn Fn(&A) -> String>,
    parse: Box<dyn Fn(&str) -> Result<A, Error>>,
    on_update: Box<dyn Fn(A)>,
) -> gtk::Entry {
    let widget = gtk::Entry::new();
    widget.set_text(&render(&value));

    let w = widget.clone();
    widget.connect_changed(move |v| match v.get_text() {
        Some(ref s) => match parse(s.as_str()) {
            ...
        },
        None => (),
    });

    widget
}

調用者必須提供一個初始值、一個’render’函數,一個’parse’函數和一個’on_update’函數。在我的實現中,驗證文本的輸入框將在每次更改後嘗試解析框中的字符串,並且僅在解析成功時才調用’on_update’函數。這樣以來,調用方負責保存數據,而不必去管解析或驗證數據是否有效的機制。
我發現,將一個表單的所有值都存儲在一個位置的模式特別有用。將所有數據存儲在一起可以讓我立即將錯誤通知給用戶,還可以檢測出由於無效數據組合而發生的錯誤,並能在出現錯誤時輕鬆禁用“提交”按鈕。

具有內部狀態的組件

2020-01-31:事實證明,我在本節的代碼中犯了一些大錯。我需要對其進行相當大的修改,以更有效地處理組件更新,並在一個GTK回調中更改組件狀態。

當我使用上面提到的這種簡單組件構建應用程序時,我將它們組合到一些更復雜的組件中,這些組件具有多份邏輯上互相歸屬,但機制上可以在各個子組件中編輯的數據。爲此,我在設置內部狀態時會獨立於子組件的狀態。

所幸我一般來說還是可以將其實現爲一個函數。

拿騎自行車來舉例,我把這個活動抽象爲一個“時間/距離”記錄。一個時間/距離事件具有一個開始時間、一個活動類型(騎自行車、步行、奔跑、皮划艇旅行…)、一段距離和一段持續時間。我的用戶界面將所有這些都綁定到一個組件中,可以一次性更新全部記錄。

pub time_distance_record_edit_c(
    record: TimeDistanceRecord,
    on_update: Box<dyn Fn(TimeDistanceRecord)>,
    ) -> gtk::Box {
}

到了這裏,我們就開始遇到Rust用來確保安全內存管理的強制規則了。
每個值都只有一個所有者。雖然你可以借用該值的引用,但是隻有這些引用超出範圍後,該值的所有者才超出範圍。此外,如果沒有其他任何類型的引用,則你只能獲得一個可變的引用。Rust Book(https://doc.rust-lang.org/stable/book/ch04-01-what-is-ownership.html)詳細討論了這些規則,並提供了大量示例和場景。

還好所有內容已經齊備了。我需要一種在多個回調函數之間共享記錄的方法,並且需要一種方法來確保對記錄的安全多線程訪問。我們用一個Arc(https://doc.rust-lang.org/std/sync/struct.Arc.html)來解決共享問題。這是一個線程安全的引用計數容器。傳遞給Arc的初始化器的所有值都歸Arc所有。克隆一個Arc會增加引用計數,並創建另一個指向該共享值的引用。

Arc不允許對其包含的值進行可變訪問,因此我們還需要包含一個RwLock(https://doc.rust-lang.org/std/sync/struct.RwLock.html)。像我們期望的那樣,RwLock允許多重讀取者,但僅允許一個寫入者,並且當存在寫入者時不允許有讀取者。於是我們像這樣來安全更改記錄:

pub time_distance_record_edit_c(
    record: TimeDistanceRecord,
    ...) -> gtk::Box {

    let record_ref = Arc::new(RwLock::new(record));

    {
        let mut rec = record_ref.write().unwrap();
        ref.activity = Cycling
    }

在代碼子塊內,'rec’成爲對記錄數據的一個可變引用。'RwLock’控制對數據的讀/寫訪問,而’Arc’允許跨函數甚至線程共享數據。
綜上所述,我們的代碼如下所示:

pub time_distance_record_edit_c(
    record: TimeDistanceRecord,
    ...
    on_update: Box<dyn Fn(TimeDistanceRecord)>,
) -> gtk::Box {

    let on_update = Arc::new(on_update);
    let record = Arc::new(RwLock::new(record));

    let duration_entry = {
        let record = record.clone();
        let on_update = on_update.clone();
        let duration = record.read().unwrap().duration.clone();
        duration_edit_c(
            &duration,
            Box::new(move |res| match res {
                Some(val) => {
                    let mut r = record.write().unwrap();
                    r.duration = Some(val);
                    on_update(r.clone());
                }
                None => (),
            }),
        )
    };
}
(注意:函數始終是隻讀的,因此僅需要'Arc'即可共享)

回顧一下,在上面的函數中,我們有一段代碼來克隆包含記錄的Arc。該克隆將移至’duration_edit_c’的回調函數中(這意味着該回調函數現在擁有這個克隆)。在這個回調函數中將可變地借用記錄、更新記錄、克隆數據並將其傳遞給’on_update’,然後將在該塊末尾自動刪除寫鎖定。

一下子要學的東西真不少。如果你不熟悉Rust,我強烈建議你閱讀有關所有權和借用系統的知識(https://doc.rust-lang.org/stable/book/ch04-01-what-is-ownership.html),這是讓內存管理無需開發人員操心,而又不會帶來垃圾收集器負擔的魔法。

從系統狀態更改來更新

最後,第四個模式涵蓋了需要響應系統更改的所有組件。用React術語來說,這意味着屬性可能從Redux更改。

在較高的層級上,我們需要一個’struct’來跟蹤在給定新數據時可能會更新的所有可視組件,以及一個將處理這些更新並返回根級小部件的’render’函數。

在這裏我用自己的History組件舉例。

struct HistoryComponent {
    widget: gtk::Box,
    history_box: gtk::Box,
}

pub struct History {
    component: Option<HistoryComponent>,
    ctx: Arc<RwLock<AppContext>>,
}

impl History {
    pub fn new(ctx: Arc<RwLock<AppContext>>) -> History { ... }

    pub fn render(
        &mut self,
        range: DateRange,
        records: Vec<Record<TraxRecord>>,
    ) -> &gtk::Box { ... }

實際上,這裏的構造函數非常簡單,除了創建抽象的’History’組件外什麼都不做。由於它沒有數據可填充到小部件中,因此它這裏甚至還沒有創建小部件。這樣非常方便,因爲在構造時組件可能會需求尚不可用的數據。
大部分工作是在’render’中完成的:

    pub fn render(
        &mut self,
        range: DateRange,
        records: Vec<Record<TraxRecord>>,
    ) -> &gtk::Box {
        match self.component {
            None => {
                let widget = gtk::Box::new(gtk::Orientation::Horizontal, 5);

                /* create and show all of the widgets */

                self.component = Some(HistoryComponent {
                    widget,
                    history_box,
                });

                self.render(prefs, range, records)
            }
            Some(HistoryComponent {...}) => {
                ..
            }
        }
    }

如果這是第一個’render’調用,則可視組件尚不存在。'Render’將創建所有組件,然後再次調用自身以使用數據填充它們。

    pub fn render(
        &mut self,
        range: DateRange,
        records: Vec<Record<TraxRecord>>,
    ) -> &gtk::Box {
        match self.component {
            None => {
                ...
            }
            Some(HistoryComponent {
                ref widget,
                ref history_box,
                ...
            }) => {
                history_box.foreach(|child| child.destroy());
                records.iter().for_each(|record| {
                    let ctx = self.ctx.clone();
                    let day = Day::new(
                        record.clone(),
                        ctx,
                    );
                    day.show();
                    history_box.pack_start(&day.widget, true, true, 25);
                });
                &widget
            }
        }
    }

在隨後的調用中,render將處理小部件的更新。如何填充新數據的細節因組件而異。在本例中我將銷燬所有現有子組件,並根據我擁有的數據創建新的子組件。這是一個非常幼稚的策略,但有時它挺好用的。

總結

就這些了。經過數週的學習,在理解如何編寫GTK的過程中我發現了四種高級模式。我覺得這些模式已經很完備了。

在撰寫本文的整個過程中,我也對我的組件做了大量修改、重構和簡化。我想這四種模式將幫助我進一步改進我的應用程序,同時我也希望在繼續學習的過程中能學到更多內容。

原文鏈接:https://savanni.luminescent-dreams.com/2020/01/15/rust-react-gtk/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章