11
5

More than 1 year has passed since last update.

ElixirDesktopで1からスタンドアローンなToDoスマホアプリを作ってみる

Last updated at Posted at 2023-07-12

はじめに

本記事はマルチプラットフォームアプリ開発を行うライブラリElixirDesktopで、
DBにSqlite3を使用してスタンドアローンで動作するスマホアプリを作る手順を紹介します

環境

M1 Mac 13.3
iOS 16.4
Phoenix 1.7.7

Elixirのインストール

基本的に上記のREADMEに沿って行います
今回は2023/07/12時最新版で試してみます
追記:erlang 26系はだめした

brew install carthage git openssl@1.1 npm
export DED_LDFLAGS_CONFTEST="-bundle"
export KERL_CONFIGURE_OPTIONS="--without-javac --with-ssl=$(brew --prefix openssl@1.1)"
asdf install erlang 25.0.4
asdf install elixir 1.15.2-otp-25
asdf global erlang 25.0.4
asdf global elixir 1.15.2-otp-25

インストール後はPhoenixをインストールします

mix archive.install hex phx_new

プロジェクトの作成

本棚アプリEbookWormerを作りますが、今回は単純なCRUDのみ行います
DBはsqlite3を使うので --database sqlite3のオプションをつけます
また、PhoenixのプロジェクトとXcodeのプロジェクトの2つが必要なので、両方のプロジェクトをいれるディレクトリを作成します

mkdir todo_root
cd todo_root
mix phx.new todo_app --database sqlite3
cd todo_app

ライブラリの追加

以下の3つのライブラリを追加します

todo_app/mix.exs
  defp deps do
    [
      ...
      {:exqlite, github: "elixir-desktop/exqlite", override: true},
      {:desktop, "~> 1.5"},
      {:wx, "~> 1.1", hex: :bridge, targets: [:android, :ios]}
    ]
  end

追加したらmix deps.getを実行しましょう

config修正

configファイルをElixirDesktopに合わせて変更します
※portの設定はバックグラウンドモードからの復帰時に必要

todo_app/config/config.exs
config :todo_app, TodoAppWeb.Endpoint,
  # because of the iOS rebind - this is now a fixed port, but randomly selected
  http: [ip: {127, 0, 0, 1}, port: 10_000 + :rand.uniform(45_000)],
  render_errors: [
    formats: [html: TodoAppWeb.ErrorHTML, json: TodoAppWeb.ErrorJSON],
    layout: false
  ],
  pubsub_server: TodoApp.PubSub,
  live_view: [signing_salt: "XYeoBJB0"],
  secret_key_base: :crypto.strong_rand_bytes(32),
  server: true

todo_app/config/runtime.exsがあるとエラーになるのでruntime_disable.exsにリネームします

Endpoint設定

EndpointのモジュールをPhoenixからDesktopに変更します
ブラウザではないのでセッション管理をcookieから etsに変更します

todo_app/lib/todo_app_web/endpoint.ex
defmodule TodoAppWeb.Endpoint do
  use Desktop.Endpoint, otp_app: :todo_app

  @session_options [
    store: :ets,
    key: "_todo_app_key",
    table: :session
  ]

アプリケーション起動時の設定

起動時のsuperviserの設定を行います

todo_app/lib/todo_app.ex
defmodule TodoApp do
  use Application

  def config_dir() do
    Path.join([Desktop.OS.home(), ".config", "todo_app"])
  end

  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    # configフォルダを掘る
    File.mkdir_p!(config_dir())

    # DBの場所を指定
    Application.put_env(:todo_app, TodoApp.Repo,
      database: Path.join(config_dir(), "/database.sq3")
    )

    # session用のETSを起動
    :session = :ets.new(:session, [:named_table, :public, read_concurrency: true])

    children = [
      TodoApp.Repo,
      {Phoenix.PubSub, name: TodoApp.PubSub},
      TodoAppWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: TodoApp.Supervisor]
    # メインのsuperviser起動
    {:ok, sup} = Supervisor.start_link(children, opts)

    # DBのマイグレーション実行
    TodoApp.Repo.initialize()

    # phoenixサーバーが起動中のポート番号を取得
    port = :ranch.get_port(TodoAppWeb.Endpoint.HTTP)
    # メインのsuperviserの配下にElixirDesktopのsuperviserを追加
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: TodoAppWindow,
          title: "TodoApp",
          size: {400, 800},
          url: "http://localhost:#{port}"
        ]
      })
  end

  def config_change(changed, _new, removed) do
    TodoAppWeb.Endpoint.config_change(changed, removed)
    :ok
  end
end

マイグレーション関連はまだなにもないですが関数だけ追加しておきます

todo_app/lib/todo_app/repo.ex
defmodule TodoApp.Repo do
  ...
  def initialize() do
  end
end

アプリケーション設定ファイルを差し替えます

todo_app/mix.exs
  def application do
    [
-      mod: {TodoApp.Application, []},
+      mod: {TodoApp, []},
      extra_applications: [:logger, :runtime_tools]
    ]
  end

iOSでの起動

設定が完了しましたので一度ここで
phoenixのフォルダからプロジェクトのフォルダに移動し、iOSのサンプルアプリをcloneします

cd ..
git clone git@github.com:elixir-desktop/ios-example-app.git

cloneしたらswfiftのライブラリを追加します

cd ios-example-app && carthage update --use-xcframeworks

次にphoenixのビルドファイルを編集します

ios-example-app/run_mix
#!/bin/bash
set -e

# Setting up the PATH environment
[ -s /opt/homebrew/bin/brew ] && eval $(/opt/homebrew/bin/brew shellenv)
[ -s /usr/local/bin/brew ] && eval $(/usr/local/bin/brew shellenv)

# This loads nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  

# This loads asdf
if [ -s "$HOMEBREW_PREFIX/opt/asdf/libexec/asdf.sh" ]; then 
  \. "$HOMEBREW_PREFIX/opt/asdf/libexec/asdf.sh" 
elif [ -s "$HOME/.asdf/asdf.sh" ]; then
  \. "$HOME/.asdf/asdf.sh"
fi

BASE=`pwd`
export MIX_ENV=prod
export MIX_TARGET=ios

mix local.hex --force --if-missing
mix local.rebar --force --if-missing

- if [ ! -d "elixir-app" ]; then
-  git clone https://github.com/elixir-desktop/desktop-- example-app.git elixir-app
- fi

- # using the right runtime versions
- if [ ! -f "elixir/.tool-versions" ]; then
-   cp .tool-versions elixir-app/
- fi

- cd elixir-app
+ cd ../todo_app

if [ ! -d "deps/desktop" ]; then
  mix deps.get
fi

- if [ ! -d "assets/node_modules" ]; then
-   cd assets && npm i && cd ..
- fi
+ # 今回はjsライブラリを使わないのでコメントアウト
+ # if [ ! -d "assets/node_modules" ]; then
+ #  cd assets && npm i && cd ..
+ # fi


if [ -f "$BASE/todoapp/app.zip" ]; then
  rm "$BASE/todoapp/app.zip"
fi

mix assets.deploy && \
  mix release --overwrite && \
  cd _build/ios_prod/rel/todo_app && \
  zip -9r "$BASE/todoapp/app.zip" lib/ releases/ --exclude "*.so"

書き換えが終わったらxcodeを開きます

open todoapp.xcodeproj

プロジェクト設定からライブラリのliberlangをEmbed & Signに変更します

スクリーンショット 2023-05-10 16.08.26.png

完了したらxcodeのstartボタンを押します

ビルドが失敗した場合は ./run_mix でビルドスクリプト単体で実行してエラーメッセージを見ましょう

ビルドが成功すれば、初回は少し時間がかかりますが、次のような画面がでてきます

スクリーンショット 2023-07-12 18.29.02.png

CRUD作成

いつものコマンドでCRUD画面を作ります

mix phx.gen.live Tasks Task tasks name:string status:boolean

特に認証はしないのでそのまま貼り付けます

todo_app/lib/todo_app_web/router.ex
  scope "/", TodoAppWeb do
    pipe_through :browser

    get "/", PageController, :home
    # 以下追加
    live "/tasks", TaskLive.Index, :index
    live "/tasks/new", TaskLive.Index, :new
    live "/tasks/:id/edit", TaskLive.Index, :edit

    live "/tasks/:id", TaskLive.Show, :show
    live "/tasks/:id/show/edit", TaskLive.Show, :edit
  end

Migration周り

ここからが本記事の本番です
ElixirDesktopでは端末ごとにmix ecto.migrateなんてことはできないので起動時にmigrationをするようにします

todo_app/priv/repo/migrationsフォルダを
todo_app/lib/todo_app/にコピーします
コピー後は xxxx_create_tasks.exsxxxx_create_tasks.exにリネームします

名前衝突を防ぐためにモジュール名をリネームします

todo_app/lib/todo_app/migrations/xxx_create_tasks.ex
- defmodule TodoApp.Repo.Migrations.CreateTasks do
+ defmodule TodoApp.Migrations.CreateTasks do
  ...
end

repo.exのinitialize関数に実行するマイグレーションのモジュール名を追加します
Ecto.Migrator.upはマイグレーションを実行する関数で
第1引数はRepoモジュール
第2引数はマイグレーション番号、今回はファイル名のタイムスタンプ
第3引数は先ほどコピーしたマイグレーションファイルのモジュール名
をそれぞれ指定します

todo_app/lib/todo_app/repo.ex
defmodule TodoApp.Repo do
  ...
  def initialize() do
    Ecto.Migrator.up(TodoApp.Repo, 20_230_712_093_326, TodoApp.Migrations.CreateTasks)
  end
end

起動時に開くページを指定

現在だと起動時にトップページが開かれるので棚一覧が開くようにします

ebookworm/lib/todo_app.ex
defmodule TodoApp do
  ...

  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    ...
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: TodoAppWindow,
          title: "TodoApp",
          size: {400, 800},
          # tasksを末尾に追加
          url: "http://localhost:#{port}/tasks"
        ]
      })
  end
end

無事CRUD画面が表示されました

4e070cd6037ae75035196a684e7cf15d.gif

微調整

一応の完成ではありますが、細かいところがスマホアプリのWebViewと合わないので微調整を行います

core_component カスタム

.table コンポーネントが横に広くてスクロールが必要になるのが嫌なので、変更する場合は
core_components.exに全てあるので、対象のコンポーネントを検索してclassを値を変更することで調整ができます

todo_app/lib/todo_app_web/components/core_components.ex:L470
  def table(assigns) do
    assigns =
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
      end

    ~H"""
    <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
-      <table class="mt-11 w-[40rem] sm:w-full">
+      <table class="mt-11 w-full"> 
    # 以下省略
    """
  end

confirm modalを追加

WebViewだと削除時に出てくる confirmやalertのダイアログは出ないので独自に実装が必要です
削除リンクをモーダルを開くナビゲーションにして
index.html.heexの末尾にconfirm modalを表示するようにします

todo_app/lib/todo_app_web/live/task_live/index.html.heex

...
<.table
  id="tasks"
  rows={@streams.tasks}
  row_click={fn {_id, task} -> JS.navigate(~p"/tasks/#{task}") end}
>
  <:col :let={{_id, task}} label="Name"><%= task.name %></:col>
  <:col :let={{_id, task}} label="Status"><%= task.status %></:col>
  <:action :let={{_id, task}}>
    <div class="sr-only">
      <.link navigate={~p"/tasks/#{task}"}>Show</.link>
    </div>
    <.link patch={~p"/tasks/#{task}/edit"}>Edit</.link>
  </:action>
-  <:action :let={{id, task}}>
+  <:action :let={{_id, task}}>
    <.link
-      phx-click={JS.push("delete", value: %{id: task.id}) |> hide("##{id}")}
-      data-confirm="Are you sure?"
+      patch={~p"/tasks/#{task}/delete"}
    >
      Delete
    </.link>
  </:action>
</.table>

...
<.modal
  :if={@live_action in [:delete]}
  id="task-delete-modal"
  show
  on_cancel={JS.navigate(~p"/tasks")}
>
  <p class="m-8">Are you sure?</p>
  <.button phx-click={JS.push("delete", value: %{id: @task.id})}>Delete</.button>
</.modal>
todo_app/lib/todo_app_web/live/task_live/index.ex
defmodule TodoAppWeb.TaskLive.Index do
  use TodoAppWeb, :live_view

  alias TodoApp.Tasks
  alias TodoApp.Tasks.Task

  ...
  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Tasks")
    |> assign(:task, nil)
  end
  # 追加
  defp apply_action(socket, :delete, %{"id" => id}) do
    socket
    |> assign(:page_title, "Delete Task")
    |> assign(:task, Tasks.get_task!(id))
  end

  @impl true
  def handle_info({TodoAppWeb.TaskLive.FormComponent, {:saved, task}}, socket) do
    {:noreply, stream_insert(socket, :tasks, task)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    task = Tasks.get_task!(id)
    {:ok, _} = Tasks.delete_task(task)

    {
      :noreply,
      socket
      # flashを追加
      |> put_flash(:info, "Task deleted successfully")
      # モーダルを閉じるように変更
      |> push_navigate(to: ~p"/tasks")
      |> stream_delete(:tasks, task)
    }
  end
end

routeにdelete actionを追加します

todo_app/lib/todo_app_web/router.ex
  scope "/", TodoAppWeb do
    pipe_through(:browser)

    get("/", PageController, :home)
    live "/tasks", TaskLive.Index, :index
    live "/tasks/new", TaskLive.Index, :new
    live "/tasks/:id/edit", TaskLive.Index, :edit
    live("/tasks/:id/delete", TaskLive.Index, :delete) # 追加

    live "/tasks/:id", TaskLive.Show, :show
    live "/tasks/:id/show/edit", TaskLive.Show, :edit
  end

開発時の小技

iOSシミュレーターで起動するとprod環境のためコードの変更は再度ビルドする必要があるため非常に効率が悪いです
なのでiex -S mixでWxWidgetで起動して、そのPhoenixサーバーにアクセスするようにします

todo_app/lib/todo_app.ex
defmodule TodoApp do
  ...
  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: TodoAppWindow,
          title: "TodoApp",
          size: {400, 800},
          # portから4000に変更
          url: "http://localhost:#{4000}/tasks"
        ]
      })
  end
end

Androidの場合はURLは以下にすれば表示されます
url: "http://10.0.2.2:#{4000}/tasks"

このときDBはiOSシミュレーターのsqliteではなく、todo_app直下にあるsqliteを参照するので注意が必要です

DEMO

ed84b7a7a81a7c511357bf43f2eb73ff.gif

追記:iPhoneSE の実機での動作も確認できました

最後に

いかがでしたでしょうか
ElixirDesktopと最新版のPhoenix+Sqlite3で外部と通信を行わないスタンドアローンなアプリを簡単に作ることができました。API等を使用して外部と通信の行わない場合はオフラインで問題なく動きます

クラウドサービスを使用することなく簡単に作ることができるのでぜひなにか作ってみて実際に審査に出してみてください
本記事は以上になりますありがとうございました

11
5
0

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
  3. You can use dark theme
What you can do with signing up
11
5