LoginSignup
2

More than 1 year has passed since last update.

Elixir Scenic ではじめる GUI 開発

Last updated at Posted at 2021-01-15

First

この記事は,ベルフェイス の有志メンバーで実施する 新年ベルリレー 12 日目のコンテンツです.
システム Gr. 開発 Div. の安部( @youknowcast ) です.

Erlang VM 上で動作する関数型言語である Elixir を使って,ぱっと動かせる GUI アプリケーションを作ってみよう,というのが本記事の主旨です.
本記事を見て,ひとりでも多くの方に Elixir の魅力に気づいていただけますと幸いです.

今回は,Scenic というライブラリを使いながら,アプリケーションを組み立ていきます.なお,Elixir の導入や基本的な文法, Mix Task 等については割愛しますので,そのあたりわからない方は雰囲気だけ見ていただけるとよいかなと思います.

また,今回のソースコードについては こちら で公開しています.

おことわり

新年ベルリレー企画は,非エンジニア職の方も多数参加してのお祭りとなっています.
そのあたり意識して,なるべく読者を置いていかないように丁寧に記載しています.あらかじめご了承ください.
また,現在,ベルフェイス社で Elixir を活用したプロジェクトはないことを明記しておきます(完全に私個人の趣味です).

今回つくるもの

今回はかんたんな画像ビューワをつくりたいと思います.
まずは仕様を決めていきましょう.

仕様を決めるってむずかしそう.どう詰めていくのがいいんでしょう?
例えば画像ビューワっていうとどんな機能があってほしいですか?
どんなんがいいかな? 絵はでてほしいな・・一覧で見られるといいな・・登録もできるといいな・・
こんな感じで考えていきます.1

まとめましょう.このときに「これはいったんいいかな・・」ということも書いておくとよいです.

* 絵が表示されてほしい
* 一覧で表示してほしい
* タグ管理したい

* 自分しか使わないのでログインはいらない
* 絵の編集はできなくていい
* 登録機能はあとまわしでよい

仕様は絵を描くのと一緒でざっくり全体を書き出してから,細部をどんどん肉付けしていきます.
こんな感じでどうでしょう?

* MacOS 上で動作するスタンドアロンアプリケーション
* 設定しておいた絵が画面上に表示される
  * 絵は同一の順番で表示される
* 絵に設定したタグが一緒に表示される
  * タグは絵に対して複数設定できる
* ページネーションができる

開発してるっぽい感じでてませんか?
これでいきましょう!2

作るものを可視化する

次にこれを実現するための図を描きます.
この工程には絶対に図が必要です.3

図には,ERD,UML,DFD といった仕様が標準化されているものからブロック図,ネットワーク図,ポンチ絵までさまざまな種類があります.
宗旨にもよるのですが,私は関係各位に正しく設計が伝わるならなんでもよいと思っています.

今回は Elixir の mix project とこれに接続する DB, それにファイル置き場を用意します.ファイル置き場はアプリケーションの中に一緒に持つことにしましょう.
これをざっくり描いたのが下記です.
architecture.png
※ この図は,draw.io + VSCode で作成したものをエクスポートしました.

なお,誰かと設計について打ち合わせするときは,完成した図を眺めるよりもその場で書き出すことをおすすめします.
このあたりについては 1/24(sun) に JTF2021w で喋るのでもしご興味あればあわせてどうぞ.

名前決め

格好いい名前を考えましょう.
今回は,風呂入りながらヨーロッパ圏にありそうな名前を空想して,melrhohien (メルロヒエン)としました.

部品集め

ここからは仕様を満たすために,実際にライブラリなどを動かして検証していきます.
まずは Scenic の動作確認,それから絵を貼ってみる,DB と接続する・・と進めます.

スタンドアロンアプリケーション(Scenic)

ネット上でなかなか活用事例が載っていないので, Scenic に組み込まれている サンプルアプリ公式ドキュメント を頼りに作業を進めていきます.
とりあえず,サンプルを動かしてみましょう.

$ mix scenic.new.example my_app
$ mix deps.get
$ mix scenic.run

動きました(断定).4

この example アプリには基本的なコンポーネントが載っているので,ソースと照らしてみることで使えそうなものを探すことが出来ます.
例えば,入力コントロール系のものはボタン,ラジオ,チェックボックス,input などひととおり揃っています.
image2.png
これを参考に我々のアプリケーションを作っていきましょう.
mix でプロジェクトを作成し,依存関係を解決します.

$ mix scenic.new melrhohien
$ mix deps.get

もちろん,以下のコマンドを叩ければ,画面が表示されます.

$ mix scenic.run

画像の貼り付け

広報から「ベルフェイスに関係あることを書いてくれ」と言われた ので,画面に bellFace ロゴを表示してみましょう.

では,lib/scenes/home.exinit/2 を変更します.この関数は,スクリーンに描画される各コンポーネントを Graph のかたちで返すようにします.
ここではロゴを貼り付けた Rectangle PrimitiveText Primitive を使用しています.

  def init(_, opts) do
    ()
    graph =
      Graph.build(font: :roboto, font_size: @text_size)
      |> add_specs_to_graph([
        rect_spec(
          {@bellface_logo_width, @bellface_logo_height},
          fill: {:image, @bellface_logo_hash},
          translate: {10, 10}),
        text_spec("we are hiring!!", translate: {450, 480}),
      ]

なお,:image に渡している @bellface_logo_hash は画像のパスを渡すのではなく,スクリーン描画する際の Texture と紐付いた Atom として渡す必要があります.5
そこで,変換処理を書きましょう.example にならうと二箇所変換箇所が必要です.

module のメタデータとして予め,ファイルを Atom にしておきます.

  @bellface_logo_path :code.priv_dir(:melrhohien)
    |> Path.join("/static/images/bellface_logo.jpg")
  @bellface_logo_hash Scenic.Cache.Support.Hash.file!(@bellface_logo_path, :sha)

ファイルから Texture に変換しましょう.example にならうとこれは init/2
の中で行います.

  Scenic.Cache.Static.Texture.load(@bellface_logo_path, @bellface_logo_hash)

mix scenic.run で表示してみましょう.
image1.png
以上で本稿でベルフェイスに関係ある内容はすべてです.
閑話休題.続けましょう.

画像リストの表示

上記で試した内容を汎用的にしていきます.
データソース(DB, これはあとで作ります)からとってきた image を動的に画面上に並べましょう.
四段階に分けてとってきます.
(1) ファイルパスと Atom の生成6 → (2) Texture への変換 → (3) Graph に設定するための spec 作成 → (4) Graph に設定!
また,あらかじめの変換はできないので,これらの処理は init/2 の中で実施していきます.

こんな感じになりました.

    file_names = [ something file names... ] # ★ あとで DB から取得するコードを書きます

    # (1)
    # こんな感じの配列ができます.
    # [
    #   # path と hash で Tupple にしています
    #   { "path/to/image", "file_hash_xxxxx" },
    #           :
    # ]
    png_path_and_hash = 
    file_names
      |> Enum.map(&(Path.join(@file_dir, &1)))
      |> Enum.map(&({&1, Scenic.Cache.Support.Hash.file!(&1, :sha)}))

    # (2)
    png_path_and_hash
      |> Enum.map(fn ({path, hash}) -> 
        Scenic.Cache.Static.Texture.load(path, hash) 
      end)

    # (3)
    png_rects =
    png_path_and_hash
      |> Enum.reduce([], fn({_, hash}, rects) ->
          rects ++ [rect_spec(
            {150, 150},
            fill: {:image, hash},
            translate: {start_width, start_height + length(rects) * 150},
          )]
        end)

    graph =
      Graph.build(font: :roboto, font_size: @text_size)
      |> add_specs_to_graph(png_rects)

縦にずらっと並ぶようになりました.
細かい調整は後回しにして(見栄えを調整するのは最後です!),次に画像の管理周りで必要な検証をやっていきます.7

画像データ管理(Ecto)

画像をいい感じに管理するために,データベース(DB)を設計します.
Elixir には強力な ORM ライブラリ Ecto があります.今回は Ecto の Sqlite3 アダプタである sqlite_ecto2 を使っていきます.

Schema

まずは DB の Schema を決めていきます.
そんな凝ったことはしないので,画像を管理する images テーブルと,これに tag を紐付ける中間テーブル,tag テーブルだけあれば十分でしょう.

image3.png

DB を作成する

DB は Ecto 経由で作成していきます.
まず,Ecto を import するように mix を設定します( ★ 部分を追加).

  def application do
    [
      mod: {Melrhohien, []},
      extra_applications: [:crypto],
      applications: [                  # ★
        :logger, :sqlite_ecto2, :ecto  # ★
      ]                                # ★
    ]

dependencies も設定します.

  defp deps do
    [
      ()
      {:sqlite_ecto2, "~> 2.2"}

追加したライブラリを import 実行します.また,続けて Ecto を使用するための Repository を作成します.

$ mix deps.get
$ mix ecto.gen.repo -r Melrhohien.Repo

Ecto はデフォルトでは PostgreSQL が設定されているので,sqlite3 を見るようにアダプタを変更します.
config/config.exs に postgreSQL 用の記述が追加されているので,以下の要領で sqlite3 向けに書き換えます.

config :melrhohien, Melrhohien.Repo,
  adapter: Sqlite.Ecto2,
  database: "melrhohien.sqlite3"

config :melrhohien, ecto_repos: [Melrhohien.Repo]

生成した Repo のアダプタ repo.ex も変更します.
ところで,デフォルトでは repo.ex は lib/melrhohien/repo.ex に生成されています.このパス構成は今回は変な感じがするので, lib/repo.ex に移動しておきます.
repo.ex は adapter: に設定するのを Sqlite.Ecto2 にすればおしまいです.

  use Ecto.Repo, otp_app: :melrhohien, adapter: Sqlite.Ecto2

最後に Repo アプリを Supervisor が起動するように設定します.
lib/melrhohien.ex を編集します.

  def start(_type, _args) do
    ()
    children = [
      {Scenic, viewports: [main_viewport_config]},
      Melrhohien.Repo,  # ★ 追加
    ]

    Supervisor.start_link(children, strategy: :one_for_one)

では,DB を作っていきましょう.コマンドを一発叩くだけです.

$ mix ecto.create

melrhohien.sqlite3 が生成されたと思います.これで DB を触る準備が出来ました.

Model を作成する

まず,DB に migration を行うためのファイルを追加します.
今回は 3 つのテーブルを追加するので,mix ecto.gen.migration でそれぞれ テーブル作成用の migration ファイルを作っていきます.

$ mix ecto.gen.migration create_images
$ mix ecto.gen.migration create_image_tags
$ mix ecto.gen.migration create_tags

これらは priv/repo/migrations/ 配下に {datetime}_create_images.exs の形式でファイルが生成されます.
テーブルの定義を追記します.

例えば,images テーブルの定義はこんな感じです.

defmodule Melrhohien.Repo.Migrations.CreateImages do
  use Ecto.Migration

  def change do
    create table(:images) do
      add :file_name, :string
      timestamps()
    end
  end
end

Ruby で ActiveRecord を触ったことがある人にはおなじみでしょう.
migration 定義完了したら DB に反映します.8

$ mix ecto.migrate

これを触るためのインタフェースが必要なので,作成します.
Scenic 側で直接 Ecto を使った Model を呼び出すのはイケてないので9,一緒にサービスレイヤを作りましょう.

Model はたとえば lib/models/image.ex は以下のような感じになります.

defmodule Melrhohien.Models.Image do
  use Ecto.Schema

  schema "images" do
    field :file_name, :string
    timestamps()
  end
end

これをサービスレイヤ lib/models.ex でくるみます.

defmodule Melrhohien.Models do

  import Ecto.Query
  alias Melrhohien.Repo

  alias Melrhohien.Models.Image
  alias Melrhohien.Models.ImageTag
  alias Melrhohien.Models.Tag

  def get_images do
    Repo.all(Image)
  end
end

seeds を作成する

今回は初期データのみで閲覧できるようにするので,投入用の seeds を作成します.
・・と言っても,Phoenix frameworkseeds.exs の実装を見ても,単に seeds.exs と名のついたスクリプトを用意して,実行するだけなので,これを真似します.

alias Melrhohien.Repo
alias Melrhohien.Models.Image
alias Melrhohien.Models.ImageTag
alias Melrhohien.Models.Tag

{:ok, img1} = %Image{file_name: "computer_cloud_system.png"} |> Repo.insert
         :

このスクリプトは以下の要領で実行できます.

$ mix run priv/repo/seeds.exs

データを取得する

これで画像の情報がとれるようになりました.
試してみましょう.

Elixir は iex というコンソールインタフェースを持っています. iex -S mix で現在見ているプロジェクトをロードしたかたちでコンソールが起動します.

iex(2)> import Ecto.Query
iex(3)> alias Melrhohien.Models  
iex(4)> Models.get_images
03:37:23.849 [debug] QUERY OK source="images" db=0.6ms decode=0.1ms
SELECT i0."id", i0."file_name", i0."inserted_at", i0."updated_at" FROM "images" AS i0 []
[
  %Melrhohien.Models.Image{
    __meta__: #Ecto.Schema.Metadata<:loaded, "images">,
    file_name: "computer_cloud_system.png",
    id: 1,
    inserted_at: ~N[2021-01-12 17:56:10.689962],
    updated_at: ~N[2021-01-12 17:56:10.695963]
  },
  :

seeds でいれたデータが取得できました.
ここまででおおよそ確認が必要な部品については出揃ったと思います.

統合

では,できたものを組み上げていきましょう.
画像を表示して,さらにタグも button_spec/2 を使って載せていきます.

スクリーンショット 2021-01-15 2.57.10.png
それっぽくなってきました.

Button は Scenic.Components が提供しています.
これを組み込む際に,画像とボタンとはまとめてひとつのコンポーネントとして扱えたほうが便利です.まとめあげるのは Scenic.Primitives の Group を使います.

変数名が古く現状と乖離しているのを一緒になおしつつ, Rectangle, Button をまとめた Group をつくります.

    png_specs =
    png_path_and_hash
      |> Enum.reduce([], fn({_, hash}, specs) ->
        ()
        specs ++ [
          group_spec(
            [
              rect_spec(
                {180, 180},
                fill: {:image, hash},
              ),
              button_spec(
                tag, 
                id: :btn_primary, 
                theme: :primary,
                t: { 20, 190 }
              ),
            ],
            t: { start_width + length(specs) * 180, start_height }
          )
        ]
      end)

Button の tag は DB から,ファイル名だけでなくタグも一緒に取得してくるように修正をしています. Ecto.Query を使っていますが,SQL わかればわりと平易に書けるかな,と思います.
(もっと効率的な書き方とかあればぜひ教えて下さい!)

  def get_file_name_and_tags(offset \\ 0) do
    (from i in Image,
      left_join: it in  ImageTag,
      on: i.id == it.image_id,
      left_join: t in Tag,
      on: t.id == it.tag_id,
      select: [i.file_name, t.name],
      offset: ^offset,
      limit: @limit,
      order_by: i.inserted_at)
      |> Repo.all
  end

ここで仕様を確認してみましょう.

  • MacOS 上で動作するスタンドアロンアプリケーション
  • 設定しておいた絵が画面上に表示される
    • 絵は同一の順番で表示される
  • 絵に設定したタグが一緒に表示される
    • タグは絵に対して複数設定できる
  • ページネーションができる

ちょっと今回はタグの複数設定に関しては,表示部分は割愛させていただきます.
※ DB 層では対応済みとなっています.

見た目の調整

まだ見た目がだいぶアレですね? ヘッダをつけてみますか.
ヘッダは, example から lib/components/nav.ex を持ってきましょう.
また,ヘッダ上に < > ボタンを載せて画像がたくさんある場合も十分表示できるようにしていきます.

スクリーンショット 2021-01-15 3.23.13.png
こんな感じになりました.

コメント

最低限動くものができたので,ここまでであとで見た人が理解できるようにしていきます.
Elixir には Document 生成の仕組み, ExDoc があります.これを使って,ドキュメントが生成されるようにしてみましょう.

mix.exs に ExDoc を追加し,mix deps.get します.
同時に project/0 に ExDoc 用の設定を追記します.

  def project do
    [
      ()

      # Docs
      name: "Melrhohien",
      source_url: "https://github.com/youknowcast/melrhohien",
      homepage_url: "https://github.com/youknowcast/melrhohien",
    ]
  end

  defp deps do
    [
      ()
      {:ex_doc, "~> 0.22", only: :dev, runtime: false},
    ]

ドキュメントを生成します.次のコマンドで doc/ ディレクトリが生成され,配下にドキュメント一式が書き出されます.

$ mix docs

image4.png

※ 今回は時間の都合でテストについては割愛します.また,別の機会にまとめていこうと思います.

Conclusion

Elixir Scenic を使った GUI アプリケーションのさわりを紹介してみました.
いろいろ書ききれなかったところがあるので,記事を更新するか続編をそのうち書くかして補足していこうと思います.

現状の Scenic だと,Electron などの HTML ベースでアプリが書けてしまう実行環境に比べるとまだまだ UI を組み上げる難易度という意味では難しそうです.
また,Web 上になかなか情報が転がっていないので,そのあたりも大変さがあります(その分,公式などのドキュメントはかなり整備されているとは思います).

しかし,なんといっても書いていて非常に楽しい言語なので,ぜひ広まって欲しいな,と考えています.
(しばらく書いてなかったせいでちょっと苦戦したとか言えない)

最後に

ベルフェイスは,現在積極採用中です.
https://recruit.bell-face.com/


  1. 今回は気軽に開発をはじめてみましょう!というテイストで検討しています. 「顧客に本当に必要なものはなにか?」を検証・学習するリーンや,システム要求の文脈から考えていく DDD などさまざまな方法論があるので,要件・設計を決めるのはもうちょっと深刻な問題なのですが, そんなことばっか言ってても動くものはできません ので. 

  2. 実際の案件では,この時点で技術者にレビューしてもらうことが大切です.開発全体に影響しそうな非機能要件はこの時点で洗い出しておかないとあとあと取り返しのつかないことになります.とはいえ,必要以上に慎重になる必要もないでしょう.まずはとりかかることとスポルスキも言っています. 

  3. ただの私見です. でも,歳をとってくると文字だけ読むというのはつらくなり,絵がないとそもそも読まない傾向が強くなってきます. 

  4. 私の手元の Elixir のバージョンは 1.11.2 (compiled with Erlang/OTP 23)Mac Mini(2018) Big Sur で動かしています.  

  5. このあたりの話は Scenic.Cache.Static.Texture に書いてあります. Cache のスコープ戦略は別途考える必要がありますが,ひとまず本稿では example の指針を踏襲しています. 

  6. この Atom は Scenic 内で image を取得するのに一意な identifier として機能します. 

  7. 早この時点で bellFace ロゴを表示するのに使っていたコードはまるっと削除しています. 

  8. sqlite3 melrhohien.sqlite3 .tables で中身を覗くと,きちんとテーブル作成されています. 

  9. Rails が MVC 構成であったために,FatController に陥りがちなどと揶揄されることもありますが,このあたり,解決するためか Phoenix framework では Contexts という概念が導入されています.Contexts は Controller や View と切り離したビジネスドメインを扱うためのレイヤで,つまり,サービス層と大雑把に言い換えてもそう問題はないはずです. ここでは同じ要領で,サービスを提供する module を定義してこれを挟んで Ecto を呼び出すようにしています. 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
2