はじめに
本記事はマルチプラットフォームアプリ開発を行うライブラリ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つのライブラリを追加します
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の設定はバックグラウンドモードからの復帰時に必要
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に変更します
defmodule TodoAppWeb.Endpoint do
use Desktop.Endpoint, otp_app: :todo_app
@session_options [
store: :ets,
key: "_todo_app_key",
table: :session
]
アプリケーション起動時の設定
起動時のsuperviserの設定を行います
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
マイグレーション関連はまだなにもないですが関数だけ追加しておきます
defmodule TodoApp.Repo do
...
def initialize() do
end
end
アプリケーション設定ファイルを差し替えます
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のビルドファイルを編集します
#!/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
に変更します
完了したらxcodeのstartボタンを押します
ビルドが失敗した場合は ./run_mix
でビルドスクリプト単体で実行してエラーメッセージを見ましょう
ビルドが成功すれば、初回は少し時間がかかりますが、次のような画面がでてきます
CRUD作成
いつものコマンドでCRUD画面を作ります
mix phx.gen.live Tasks Task tasks name:string status:boolean
特に認証はしないのでそのまま貼り付けます
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.exs
をxxxx_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引数は先ほどコピーしたマイグレーションファイルのモジュール名
をそれぞれ指定します
defmodule TodoApp.Repo do
...
def initialize() do
Ecto.Migrator.up(TodoApp.Repo, 20_230_712_093_326, TodoApp.Migrations.CreateTasks)
end
end
起動時に開くページを指定
現在だと起動時にトップページが開かれるので棚一覧が開くようにします
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画面が表示されました
微調整
一応の完成ではありますが、細かいところがスマホアプリのWebViewと合わないので微調整を行います
core_component カスタム
.table コンポーネントが横に広くてスクロールが必要になるのが嫌なので、変更する場合は
core_components.exに全てあるので、対象のコンポーネントを検索してclassを値を変更することで調整ができます
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を表示するようにします
...
<.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>
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を追加します
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サーバーにアクセスするようにします
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
追記:iPhoneSE の実機での動作も確認できました
最後に
いかがでしたでしょうか
ElixirDesktopと最新版のPhoenix+Sqlite3で外部と通信を行わないスタンドアローンなアプリを簡単に作ることができました。API等を使用して外部と通信の行わない場合はオフラインで問題なく動きます
クラウドサービスを使用することなく簡単に作ることができるのでぜひなにか作ってみて実際に審査に出してみてください
本記事は以上になりますありがとうございました