JSを書かずにWebアプリを作る

※ 他サービスで書いたエントリの再掲になります。 前に、自分はrustでAPNGのライブラリを作りました。(良ければみてください 記事) また、別のweb appを作った記事もここにあります。

poccariswet.hatenablog.com

今回、作ったライブラリの活用手段として、Webアプリ化と自分の中で考え、Webアプリを作ろうと思いました。

JSを書かない

JSを書きたくない 書かない理由としては、rustはwasmを使ってWebアプリが書けることと、JSよりも高速かつメモリセーフといったようなメリットがあるためです。また、個人的に静的型付け言語が好きだからです。

作ったもの

もうすでに公開されているので、ソースコードWebアプリが気になる方は先に見てみてください。

https://drawasm.netlify.com generated APNG

作ったアプリを簡単に説明すると、Web上で絵や文字を描きそれをAPNGに変換できるものです。

今回はその作ったもの説明を以下の順序で話していければと思います。

目次

下準備

まず、rustでwasmを書くためにツール群のインストール、そしてテンプレートを使用して準備をしていきます。

wasmのツールをインストールし終わったら、

$  cargo generate --git https://github.com/poccariswet/rust-wasm-template

これは自分が作った、templateで上記のコマンドをうって rustのwasmプロジェクトを作成していきます。

github.com

wasmで実装したところ

canvas部分について

絵や文字書くための場所であるcanvas部分については、自分が以前書いた記事の wasmのEventListenerの実装をコードとともに見てみるに飛んでみてください。 ここには、使っているライブラリ(web-sys)などやEventListenerについて詳しくまとめているので読んでいただけたらと思います。

state部分について

今回の実装上、色やペンの太さ、キャンバスの画像の保持などを複数のデータや状態を共有しなければなりません。そのため、rustでは共有スマートポインタである、Arc/Rc を使用します。今回はスレッドでの共有がないと考えているため、Rc を使っていきます。

  • Rcは、同じデータに複数の所有者を持たせてくれる; BoxとRefCellは単独の所有者。(*1)
  • Boxは、不変または可変借用をコンパイル時に精査してくれる; Rcは不変借用のみをコンパイル時に精査してくれる; RefCellは、不変または可変借用を実行時に精査してくれる。(*1)

状態共有

まず、状態共有をするには 内部可変性コンテナであるRefCellを使います。

  • RefCellは実行時に精査される可変借用を許可するので、RefCellが不変でも、 RefCell内の値を可変化できる(*1)

また、今回のアプリでは複数のオブジェクトから共有されている状態を持つので以下のように宣言します。

https://github.com/poccariswet/drawasm/blob/master/src/lib.rs#L60-L61

let state: Rc<RefCell<state::State>> = Rc::new(RefCell::new(state::State::new(canvas_w, canvas_h)));

このように宣言することで、複数のオブジェクト間での状態の共有ができます。

toolbar部分について

アプリのこの部分です。 Screen Shot 2020-01-08 at 20.15.13.png

ここでは、クリックイベントを取得して、それに応じてキャンバスに描くモードを変えたり、プレビューエリアへのプレビュー画像の追加をしていきます。

クリックイベントを取得する

https://github.com/poccariswet/drawasm/blob/master/src/toolbar.rs#L159-L187

fn create_color_picker(
    document: &Document,
    state: &Rc<RefCell<State>>,
) -> Result<Element, JsValue> {
    // divタグの生成
    let element = document.create_element("div")?;
    // toolbar用にスタイルの設定
    element.set_attribute(
        "style",
        "height: 50px; width: 50px; display: flex; align-items: center; justify-content: center; font-size: 11px; border: 1px solid #9b9b9b;",
    )?;

    // inputタグの生成
    let input = document
        .create_element("input")?
        .dyn_into::<HtmlInputElement>()?;
    // inputのattributeを設定
    // inputのタイプをcolorにしてvalueをblackに設定
    input.set_attribute("type", "color")?;
    input.set_attribute("value", "#000000")?;

    // stateをクローンして、Closure内で使用する
    let state = state.clone();
    let picked_color = Closure::wrap(Box::new(move |e: Event| {
        let target = e.target().unwrap().dyn_into::<HtmlInputElement>().unwrap();
        let color = target.value();
        state.borrow_mut().set_color(color)
    }) as Box<dyn FnMut(_)>);
    input.add_event_listener_with_callback("change", picked_color.as_ref().unchecked_ref())?;
    picked_color.forget(); //⚠️注意点
    element.append_child(&input)?;

    Ok(element)
}

上記の実際に、実装されたコード参考に見ていきます。

ここでやっていることは大きく分けて2つあります。

1つ目は、タグの生成、タグスタイルの設定

Documentcreate_elementを使用して、elementの生成をします。生成されたelementにset_attributeを使用して、styleの適用をしていきます。今回はカラーパレットを使うのでtypecolorにしています。

2つ目は、jsで言うEventListenerのイベント制御の設定

inputのカラーが changeされた際に、valueをstateのcolorに保存する。“  これがここのイベント制御の内容です。

JSのEventListenerのClosureを実装するために、rustのクロージャをjsのクロージャに変換する、Closure::wrap メソッドを使用します。

その中で、InputEventtarget.valueを取得したいので、event.targetHtmlInputElementdyn_intoをして、Eventの型をキャストします。キャスト後にvalue()でカラーパレットで選択されたカラーを取得します。

Closureを実装し終えた後に、inputタグにadd_event_listener_with_callbackEventListenerの設定をします。

EventListenerをセットする際の注意点(forget)

以前の記事でも言っているのですが、作成したClosureをforgetすることで意図的にメモリリークしています。(メモリリークと言っていますが、「このクロージャをリークして、プログラム全体の期間中有効にする」と言う意味です。)

こう言った理由で、Closureをforgetしておかないと、スコープを抜けた際にdropしてしまうのでEventListenerとして活用できなくなってしまいます。

以上のイベントを受け、canvas内では https://github.com/poccariswet/drawasm/blob/master/src/draw.rs#L24-L55

    // mousedown
    {
        let context_copy = context.clone();
        let state_copy = state.clone();

        let mouse_down = Closure::wrap(Box::new(move |event: MouseEvent| {
        ...
         context_copy.set_stroke_style(&JsValue::from(state_copy.borrow().get_color()));
         context_copy.set_line_width(state_copy.borrow().get_pen_thin());
         context_copy.move_to(new_x, new_y);
        ...

のように、stateに保存されたcolorをmousedown時(絵を描くとき)に取得して、set_stroke_styleメソッドを使用して描く色を変えています。
残りのtoolbar部分については、説明したinputの挙動と似ているので省略します(preview部分は次で説明)。

canvaspreview部分について

さて次はcanvasに描いた絵をaddした際のpreview部分についてです。 Screen Shot 2020-01-10 at 15.50.57.png

見てわかる通り、cssでbox-shadowでくぼみを作っているところがpreviewエリアになります。 ここに画像を追加するIconをクリックするとcanvasに書かれている画像が追加され、プレビューされます。

ここの実装は以下を見るとわかるようにまた、Closure内(クリックイベント)での実装となりまうす。 https://github.com/poccariswet/drawasm/blob/master/src/toolbar.rs#L265-L303

let handle_click = Closure::wrap(Box::new(move || {
        let img = document_copy
            .create_element("img")
            .unwrap()
            .dyn_into::<HtmlImageElement>()
            .unwrap();

        let url = canvas.to_data_url_with_type("image/png").unwrap();
        state.borrow_mut().add_preview_image(url.clone());

        // img set_src URL string
        img.set_src(&url);
        img.set_attribute("class", "preview-img").unwrap();
        img.set_width(state.borrow().get_preview_width());
        img.set_height(state.borrow().get_preview_height());
        preview.append_child(&img).unwrap();
    }) as Box<dyn FnMut()>);

このクロージャ内では、previewエリアに追加するためのimgタグの生成をし、そのimgタグのsrcに、canvasのメソッドである、to_data_url_with_type("image/png")を使用してBase64にしたimage情報を追加します。また、タイプがpngになっているのは後々APNGをgenerateするためです。

その後は、前と同じようにset_attributeset_widthset_heightで付加情報をつけ、previewのchildとして、追加します。

APNGをgenerateする部分について

最後にプレビューされた画像をAPNGを作成する部分についてです。 https://github.com/poccariswet/drawasm/blob/master/src/generate.rs#L96-L166

ここでは、プレビューする際にstateに追加したbase64エンコードされた画像データをdata:image/png;base64,部分だけ取り除き、逆にデコードしu8のbufferにします。

rustのimage::load_from_memory_with_formatimage::DynamicImage(PNG)にエンコードします。その後に、自分で自作したAPNGのライブラリにその値を入れAPNGにエンコードしていきます。

エンコード後、Vec<u8>のbufferが出来上がるので、それをblobに変換して、window.open_with_urlで開けるようにしていきます。

https://github.com/poccariswet/drawasm/blob/master/src/generate.rs#L149-L159

        // ⚠️bufはAPNGにエンコードされたbuffer  
        let b = js_sys::Uint8Array::new(&unsafe { js_sys::Uint8Array::view(&buf) }.into()); 
        let array = js_sys::Array::new();
        array.push(&b.buffer());
        let blob = Blob::new_with_u8_array_sequence_and_options(
            &array,
            BlobPropertyBag::new().type_("image/png"),
        )
        .unwrap();
        let url = Url::create_object_url_with_blob(&blob).unwrap();
        let window = window().unwrap();
        window.open_with_url(&url).unwrap();

Blob::new_with_u8_array_sequence_and_optionsを使用し、blob作成していきます。 その前に、Blobを作成するためにbuf(APNGエンコード後のbuffer)をjs側(browser側)でも相互運用できるようにUint8Array型に変換し線形メモリへビューします。

https://rustwasm.github.io/wasm-bindgen/api/js_sys/struct.Uint8Array.html#method.view

Views into WebAssembly memory are only valid so long as the backing buffer isn't resized in JS. Once this function is called any future calls to Box::new (or malloc of any form) may cause the returned value here to be invalidated. Use with caution!

のように、js_sys::Uint8Array::view()を使用する際には、unsafe{}ブロックを使用しなければなりません。

https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob Syntax

var newBlob = new Blob(array, options);

array: An Array of ArrayBuffer, ArrayBufferView, Blob, USVString objects, or a mix of any of such objects, that will be put inside the Blob. USVString objects are encoded as UTF-8.

あとは、MDNに描いてあるように、blobを作成するための引数に描いてある通り、js_sys::Array::new()でArrayを作成し、中にキャストしたUint8Arrayを追加し、blobを作成していきます。

blobを作成後は、コード通り、urlを作成し、open_with_urlで次のタブに出来上がった画像をプレビューします。

こんな感じです。 preview


あとは、この書いたコードをbuildし、WebAssemblyを吐き出してjs or html内でrun()して終わりです!

今後

一旦ここで完成ですが、色々と機能を増やしたいことがあるのでwasmで行けるところまで挑戦したいと思います。 以下にやっていこうと思うことを並べていきます。

  • gifエンコードのサポート
  • うごメモのようにclearしても背景に前に描いたやつが薄く見える状態にする
  • 絵を描くときの透過性を変えれるボタンの追加
  • previewされた画像を一枚一枚編集/削除できる ...etc

終わりに

ここまで読んでいただきありがとうございます。 これを読んでいただいた方が、rustでwasmをやる興味を持っていただけたり、自分の記事がwasmをさわる際の参考にしてもらえれば幸いです。

また、JS書きたくないけど、Webアプリ作りたいと言う方が入ればぜひ試してみてください!

また、記事を読んでいて、疑問点やここ違くない?と言ってようなことがあればぜひ教えていただきたいのでコメント待ってます!

reference