【JavaScript】【Vue.js】Vuex でも型補完がほしい
前書き
最近お仕事で Vue.js + Vuex をモリモリやっているんですが、せっかく VSCode を使っているのに公式ドキュメントに書いてある方法をそのまま使うと型補完が効かないのでしんどい気持ちになってきました。
commit
/ dispatch
で指定する名前とかペイロードとか、state
から参照するメンバ名とその型とか、さくさく補完して欲しくないですか?IntelliSense に出てこないものは存在しないものと同義じゃないですか?
そういった辛いお気持ちをどうにかするため、色々やってみたメモです。
前提・環境
- Vue CLI
- Webpack を使って単一ファイルコンポーネントをいじり回せる環境が前提。
- Visual Studio CodeとVue.js Extension Pack
- その他のエディタでは書いたことないので…。
- 型補完は欲しいが JavaScript(ES6)で頑張る。
- TypeScript が使えればもっと楽な箇所がいっぱいあると思う。
仕組みの概要
- コンポーネント内では
$vm.store
を参照するのではなく、import
でストアのインスタンスを取得する。 - マッピング用のヘルパー関数(
mapState
とか)は使わない。*1 - ステート用のクラスを作る。
- ゲッターはステート用のクラスに関数として定義する。
commit
/dispatch
に指定する名前とペイロードを持つクラスを作る。modules.namespaced
による名前空間の管理を行わない。- 全部グローバル空間に突っ込むことになるが、
commit
/dispatch
に指定する名前が一意になっていれば問題ない。やり方は後述。 - ちなみに
modules.namespaced
がtrue
になっていなくても、ステートはstore[${モジュール名}]
でアクセスできる。
- 全部グローバル空間に突っ込むことになるが、
- 型情報が失われてしまう箇所はJSDocを書いて情報を補完する。*2
サンプルコード
サンプルとなるコードがないと説明しづらいので適当に例を載せておきます。
また、Githubに今回の動作検証用プロジェクトをあげてあります。今回の論旨に関係ない部分はめちゃめちゃ適当に作ったので、そのまま流用すると痛い目に合うと思います。
ストアの定義
要点は以下の通りです。
- 検索処理用のモジュール
- ステートとして検索結果を持つ
- アクションに検索処理が定義されている
import Vue from "vue"; import Vuex from "vuex"; Vue.use(Vuex); const store = new Vuex.Store({ modules: { search: { state: { result: [] }, mutations: { setResult(state, payload) { state.result = payload; } }, actions: { async search({ commit }, payload) { const body = JSON.stringify(payload); const resp = await fetch("/search", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" }, body }); commit("setResult", resp.json()); } } } } }); export default store;
コンポーネントの定義
先程のストアがマッピングされている検索用フォーム(src/components/SearchForm.vue
)と検索結果(src/components/SearchResult.vue
)のコンポーネントです。
<template> <div> <label> ID: <input type="text" @input="id = $event.target.value" /> </label> <button @click.prevent="onSearch">Search</button> </div> </template> <script> export default { name: "SearchForm", data() { return { id: "" }; }, methods: { async onSearch() { await this.$store.dispatch("search", { id: this.id }); } } }; </script>
<template> <table v-if="result.length > 0"> <thead> <th>ID</th> <th>Name</th> </thead> <tbody> <tr v-for="item in result" :key="item.id"> <td>{{item.id}}</td> <td>{{item.name}}</td> </tr> </tbody> </table> </template> <script> export default { name: "SearchResult", computed: { result() { return this.$store.state.search.result; } } }; </script>
モジュールを別ファイルに分割する 9dafad2
とりあえずモジュールを分割しましょう。こんな感じのディレクトリ構成にします。
src └─store │ index.js └─modules └─search actions.js index.js mutations.js namespace.js state.js
store/modules/search/index.js
でモジュールと名前空間をエクスポートするようにします。
// store/modules/search/index.js import state from "./state"; import mutations from "./mutations"; import actions from "./actions"; const module = { state, mutations, actions }; export default module; export { default as nsSearch } from "./namespace";
// store/modules/search/namespace.js export default "search";
state.js
/ mutations.js
/ actions.js
は先程のストア定義からそのまま切り出すだけです。
// store/modules/search/state.js const state = { result: [] }; export default state;
// store/modules/search/mutations.js const mutations = { setResult(state, payload) { state.result = payload; } }; export default mutations;
// store/modules/search/actions.js const actions = { async search({ commit }, payload) { const body = JSON.stringify(payload); const resp = await fetch("/search", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" }, body }); commit("setResult", resp.json()); } }; export default actions;
store/index.js
でモジュールをインポートし、Vuex.Store
のインスタンスを作るようにします。
// store/index.js import Vue from "vue"; import Vuex from "vuex"; import searchModule, { nsSearch } from "./modules/search/index"; Vue.use(Vuex); const store = new Vuex.Store({ modules: { [nsSearch]: searchModule } }); export default store;
これでモジュールが増えてもそれなりに管理しやすくなりました。
ステートのクラス化 8287dd
まずはステートをクラスにしてしまいましょう。何にも考えずにやると、こんな感じでしょうか。
/** * 検索用ステート */ class SearchState { /** * 検索用ステート */ constructor() { /** 検索結果 */ this.result = []; } } export { SearchState }; export default new SearchState();
クラスの定義(export { SearchState }
)とシングルトンインスタンス(export default new SearchState()
)は別々にエクスポートしておきます。理由は後で定義だけ参照したくなるからです。
ステートに定義されたオブジェクトのクラス化
このままだとSearchState#result
の型はany[]
となってしまいます。これでは片手落ちなので、ちゃんと検索結果用のクラスも作ってあげます。
// store/modules/search/models.js /** * 検索結果 */ class SearchResult { /** * 検索結果 */ constructor() { /** ID */ this.id = ""; /** 名前 */ this.name = ""; } } export { SearchResult };
state.js
側では@type
を指定して型情報を補完してあげます。
import * as Models from "./models"; /** * 検索用ステート */ class SearchState { /** * 検索用ステート */ constructor() { /** * 検索結果 * @type {Models.SearchResult[]} */ this.result = []; } } export { SearchState }; export default new SearchState();
これでSearchModule#result[n]
からid
やname
が補完されるようになりました。
ステートのエクスポート 461a29
とはいえ、今のままではコンポーネント側で参照するときに補完が効きません。意味ないですね。
そこでちゃんと型情報を付与してあげつつ、ストアとは別にステートをエクスポートするようにします。
// store/modules/search/index.js import state from "./state"; import mutations from "./mutations"; import actions from "./actions"; const module = { state, mutations, actions }; export default module; // 型定義だけエクスポート export { SearchState } from "./state"; export { default as nsSearch } from "./namespace";
// store/index.js import Vue from "vue"; import Vuex from "vuex"; // 型定義 (SearchState) をインポートに追加 import searchModule, { nsSearch, SearchState } from "./modules/search/index"; Vue.use(Vuex); const store = new Vuex.Store({ modules: { [nsSearch]: searchModule } }); export default store; // エクスポートする時に @type を指定してあげるとインポート先で型情報が補完される /** @type {SearchState} */ export const searchState = store.state[nsSearch];
後はコンポーネントでsearchState
をインポートしてやれば OK です。
<template> <table v-if="result.length > 0"> <thead> <th>ID</th> <th>Name</th> </thead> <tbody> <tr v-for="item in result" :key="item.id"> <td>{{item.id}}</td> <td>{{item.name}}</td> </tr> </tbody> </table> </template> <script> // searchState をインポート import { searchState } from "../store/index.js"; export default { name: "SearchResult", computed: { result() { // $vm.store ではなくインポートしたステートを直接参照 // return this.$store.state.search.result; return searchState.result; } } }; </script>
副次効果として、コンポーネント内のmethods
などからthis.result
を参照するとちゃんとSearchResult[]
として扱われます。最高ですね。
MutationType
/ ActionType
の作成 37fa2d
次はcommit
/ dispatch
に指定する名前とペイロードがあやふやすぎる問題をどうにかしていきましょう。
commit
/ dispatch
ともに、第一引数に名前を渡す代わりにtype
を持つオブジェクトを渡すことが許されているので、これを活用します。
// 以下はどちらも mutations.setResult に対し { result: [] } を渡す commit("setResult", { result: [] }); commit({ type: "setResult", result: [] }); // 以下はどちらも actions.search に対し { param: {} } を渡す dispatch("search", { param: {} }); dispatch({ type: "search", param: {} });
具体的には、type
を参照するとcommit
/ dispatch
で呼び出すべき名前が返ってくるクラスを作成します。
MutationType
の定義
まずはミューテーションから。
// store/modules/search/mutationTypes.js import namespace from "./namespace"; /** * 検索結果設定 */ export class SetResult { get type() { return SetResult.type; } static get type() { return `${namespace}/SetResult`; } }
そしてこのクラスのコンストラクタにペイロードを指定するようにすれば完璧です。
// store/modules/search/mutationTypes.js import namespace from "./namespace"; import { SearchResult } from "./models"; /** * 検索結果設定 */ export class SetResult { /** * 検索結果設定 * @param {SearchResult[]} result 検索結果 */ constructor(result) { /** * 検索結果 * @type {SearchResult[]} */ this.result = result; } get type() { return SetResult.type; } static get type() { return `${namespace}/SetResult`; } }
これでnew SetResult(result)
という形でインスタンスを作れば{type: "search/SetResult", result: result}
というオブジェクトを得られるようになりました。
また、@param
を記述することでペイロードとして渡すべき型の情報も明示されるようになったため、より安全にcommit
を呼び出すことが可能になります。
さらにtype
でnamespace
を含めて返すようにすることで、実質namespaced
をtrue
にしたのと同じ効果を得ることができます。
さらにさらに、今まで作ったクラスを組み合わせることでmutations
内の関数でも型補完を効かせることができるようになります。
// store/modules/search/mutations.js import { SearchState } from "./state"; import * as types from "./mutationTypes"; const mutations = { /** * 検索結果設定 * @param {SearchState} state * @param {types.SetResult} payload */ [types.SetResult.type](state, { result }) { state.result = result; } }; export default mutations;
ActionType
の定義
アクションも同じようなものを作れば OK です。
// store/modules/search/actionTypes.js import namespace from "./namespace"; /** * 検索処理 */ export class Search { /** * 検索処理 * @param {string} id ID */ constructor(id) { /** * ID * @type {string} */ this.id; } get type() { return Search.type; } static get type() { return `${namespace}/Search`; } }
// store/modules/search/actions.js import { ActionContext } from "vuex"; import * as types from "./actionTypes"; import * as mutationTypes from "./mutationTypes"; const actions = { /** * 検索処理 * @param {ActionContext} context * @param {types.Search} payload */ async [types.Search.type]({ commit }, { id }) { const body = JSON.stringify({ id }); const resp = await fetch("/search", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" }, body }); // action 内で commit を呼ぶ時も当然 MutationType を使える commit(new mutationTypes.SetResult(resp.json())); } }; export default actions;
MutationType
/ ActionType
のエクスポート
後はこれらをエクスポートしてあげればコンポーネントからも自由に使うことができます。
今はモジュールも一つ、MutationType
もActionType
も一つずつしか定義されていませんが、これらがどんどん増えていくとインポートが大変なので、store/modules/search/index.js
では* as SearchMutation / SearchAction
として一度インポートしておき、そのままエクスポートしておくと良いです。
// store/modules/search/index.js import state from "./state"; import mutations from "./mutations"; // MutationType import * as SearchMutation from "./mutationTypes"; import actions from "./actions"; // ActionType import * as SearchAction from "./actionTypes"; const module = { state, mutations, actions }; export default module; export { SearchState } from "./state"; export { default as nsSearch } from "./namespace"; // MutationType / ActionType をエクスポート export { SearchMutation, SearchAction };
// store/index.js import Vue from "vue"; import Vuex from "vuex"; import searchModule, { nsSearch, SearchState } from "./modules/search/index"; Vue.use(Vuex); const store = new Vuex.Store({ modules: { [nsSearch]: searchModule } }); export default store; /** @type {SearchState} */ export const searchState = store.state[nsSearch]; // MutationType / ActionType をエクスポート export { SearchMutation, SearchAction } from "./modules/search/index";
コンポーネントからは以下のように呼び出します。
<template> <div> <label> ID: <input type="text" @input="id = $event.target.value" /> </label> <button @click.prevent="onSearch">Search</button> </div> </template> <script> // SearchAction をインポート import store, { SearchAction } from "../store/index.js"; export default { name: "SearchForm", data() { return { id: "" }; }, methods: { async onSearch() { // dispatch の引数を SearchAction.Search のインスタンスに変更 // await this.$store.dispatch("search", { id: this.id }); await store.dispatch(new SearchAction.Search(this.id)); } } }; </script>
ゲッターの扱いについて 279266
後 Vuex の機能として残っているのはゲッターぐらいなんですが、実はこれ、state
のクラスに関数として定義してしまっても特に問題ありません。
というか、store.getters
経由だとどう頑張っても型補完は無理です。*3
大人しくstate
内に定義しましょう。
// store/modules/search/state.js import * as Models from "./models"; /** * 検索用ステート */ class SearchState { /** * 検索用ステート */ constructor() { /** * 検索結果 * @type {Models.SearchResult[]} */ this.result = []; } // これをゲッターの代わりにする get sortedByIdResult() { return this.result.sort((a, b) => { const idA = a.id.toUpperCase(); const idB = b.id.toUpperCase(); if (idA < idB) { return -1; } if (idA > idB) { return 1; } return 0; }); } } export { SearchState }; export default new SearchState();
まとめ
と言うわけで、ここまでやると Vuex で型補完をバリバリ活用することができます。
そこそこ面倒な感じがするかもしれませんが、これぐらいやっておかないと大規模なアプリを作るときにかなりしんどいです。かなりしんどいです。今。ナウ。
【PowerShell】PowerShellの$LASTEXITCODEとcmdの%ERRORLEVEL%が一致しないケース
前書き
約二年ぶりの更新です。元々メモ書きのようなブログですが、ここ二年ほどメモするほどの内容がありませんでした。
Spring Boot とか、Flutter とか、vue.js とか、Azure のアレコレとか、AWS のアレコレとか、Firebase のアレコレとか、色々やってたんですけどね。
上記のうち、Flutter の話はいつかしますが、今日は PowerShell の話です。
$LASTEXITCODE
とは
PowerShell には自動変数と呼ばれる、一種の予約語のような変数があります。
$LASTEXITCODE
はその中の一つであり、
最後に実行された Windows ベースのプログラムの終了コードが格納されます。
と書いてある通り、他プロセスの実行結果が格納されます。
.bat
でバリバリとバッチ処理を作ったことがある人なら「あ、%ERRORLEVEL%
みたいなもんね。」と思うかもしれません。合ってます。合っていますが、完全に%ERRORLEVEL%
と互換性があるわけではなく、それが原因でめちゃめちゃにハマりました。
$LASTEXITCODE
と%ERRORLEVEL%
が一致しなくなるバッチ
こんな.bat
ファイルを作ります。サブルーチンの方でexit /b 1
を返し、メイン処理は特に何も返さない、それだけの処理です。
@echo off call :hoge exit /b :hoge exit /b 1
作った.bat
ファイルをコマンドプロンプトで呼び出し、%ERRORLEVEL%
を調べます。普通に1
が返ってきます。
> test.bat > echo %ERRORLEVEL% 1
PowerShell で同じように呼び出し、$LASTEXITCODE
を調べます。
> .\test.bat > $LASTEXITCODE 0
$LASTEXITCODE
は%ERRORLEVEL%
を見ているわけではないので、メイン処理の返り値を取得してしまいます。よって、0
が表示されます。
ワークアラウンド
自分で作った.bat
ならメイン処理でexit /b %ERRORLEVEL%
とすればいいんですが、諸々の事情*1で.bat
の方を直せない場合、PowerShell 側で何とかする必要があります。
考え方は簡単です。明示的にcmd.exe /C
でバッチを叩いた後、exit %ERRORLEVEL%
すればいいだけです。
&
でコマンドを連続実行してやればワンライナーで楽勝です。遅延環境変数を展開できるよう/V:ON
の指定を忘れずに。
> cmd /V:ON /C "test.bat & exit !ERRORLEVEL!" > $LASTEXITCODE 1
まとめ
うーん。めんどくさい。
【Git】他人のプロジェクトをsubmoduleで追加し、Pull Requestを自分でマージする方法
前書き
色々なライブラリを調べていると、「おっ、この PR いいじゃーん」と思ってもメンテナが全然やる気を出さないせいで一向にmaster
に取り込まれないことが多々あり、master
をsubmodule
で引っ張ってきた後、PR をマージするってことをたまにやるんですが、たまにしかやらないからか方法を毎回忘れるのでメモしておきます。
サブモジュールの追加
git submodule add
すれば OK
git submodule add ${cloneしたいリポジトリのURL} ${新規に作るディレクトリ名}
PR のマージ
マージ用のブランチ作成
サブモジュールのディレクトリで適当なブランチを作ってそっちに切り替えておきましょう。*1
git checkout -b work
PR のマージ
以下のコマンドでpull
してきましょう。
git pull https://github.com/${PRを送ってきた人のID}/${プロジェクト名} ${PRを送ってきた人のブランチ名}
まとめ
特に何も言うことはないです。
*1:うっかり pull しちゃってもこれで安心ですね。
【JavaScript】明日から使えるRiot.js
前書き
相変わらず Web エンジニアみたいなお仕事をしているんですが、最近訳あって IE 8 からの呪縛から解き放たれました。
で、前々から使ってみたかったんだけど機会がなかったRiot.jsを使うことができるようになったので、ここ一ヶ月ほどもりもり書いていました。
ある程度の知見を得ることができたので、適当にメモしていきます。
Riot.js の目的
割とReactと比較されることが多い…と言うか、自分でReact の目指すところは悪くないんだけどあの文法はいくらなんでもクソすぎと言っている*1んですが、最終目標はDOM 操作における jQuery みたいな立ち位置を Web Components で目指すところっぽいです。Polymerの方が実質的なライバルでしょう。*2
また、ちょっとしたデータバインディングもできる*3ためか、Vue.jsからイチャモンをつけられているんですが、そもそもお互いが目指すゴールが違うので比較すること自体がなんだか妙な話です。*4
なお、あくまでライブラリであって、フレームワークではないです。「これ一本で Web アプリをバリバリつくるんじゃ~い!」と思うのであれば、AngularJSでも使ったほうがいいと思います。
つい最近Shadow DOM v1がとうとう Chrome に実装されたことでちょっとしたニュースになりましたが、あれもまだまだ気軽に使えるとは言いがたい*5ので、こう言うライブラリでもっと楽できればいいよね、みたいな気楽なスタンスです。
基本的な使い方
できる限り簡単に Web Components を実装したい!が最終目的なので、公式のガイドを 30 分~ 1 時間ぐらいかけて読めばほとんど全機能を使うことができます。
が、流石に実例がないとわかりづらいので、Bootstrap のサンプルを黙々と Riot.js に書き換えていく作業をやってみましょう。
Bootstrap も「CSS によるコンポーネント化を目指す」みたいなところがあるので、Web Components の概念と非常に相性がよくサンプルとして最適ですし、わかりやすく実用性があります。*6
また、読むだけだとやっぱりわかりづらいので、適当なリポジトリを作っておきました。Express で動くので、Node.js が入ってればすぐ遊べます。
元のソース
面倒なので<body>
以下のところを一部引っこ抜いてきました。
<body> <nav class="navbar navbar-default navbar-fixed-top"> <div class="container"> <div class="navbar-header"> <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar" > <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> <a class="navbar-brand" href="#">Project name</a> </div> <div id="navbar" class="navbar-collapse collapse"> <ul class="nav navbar-nav"> <li class="active"><a href="#">Home</a></li> <li><a href="#about">About</a></li> <li><a href="#contact">Contact</a></li> </ul> </div> </div> </nav> <div class="container"> <div class="jumbotron"> <h1>Navbar example</h1> <p> This example is a quick exercise to illustrate how the default, static and fixed to top navbar work. It includes the responsive CSS and HTML, so it also adapts to your viewport and device. </p> <p> To see the difference between static and fixed top navbars, just scroll. </p> <p> <a class="btn btn-lg btn-primary" href="../../components/#navbar" role="button" >View navbar docs »</a > </p> </div> </div> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script> <script type="text/javascript" src="./javascripts/bootstrap.min.js"></script> </body>
どっから手を付けて行こうかな、って感じですが、簡単そうな<div class="jumbotron">
からいきますか。
カスタムタグと<yield/>
[diff]
まずは適当な.tag
ファイルを作ります。jumbotron なんだから、jumbotron.tag
とかでいいんじゃないですかね。(適当)
作り方はとても簡単です。
- ルート要素として新しく作りたいタグ(今回は
<jumbotron>
)を指定する。 - ルート要素の配下に実際に表示される内容を書く
<jumbotron> <div class="jumbotron"> <yield/> </div> </jumbotron>
これで終わりです。<yield/>
を指定することで、呼び出し元のカスタムタグの中に記述された HTML をそのまま読み込んでくれます。
次は実際にこのカスタムタグを先ほどの html で読み込んでみましょう。これもとても簡単です。
riot+compiler.min.js
を読み込むscript type="riot/tag"
を指定し、.tag
を読み込むriot.mount
でマウントする
<div class="container"> <jumbotron> <h1>Navbar example</h1> <p> This example is a quick exercise to illustrate how the default, static and fixed to top navbar work. It includes the responsive CSS and HTML, so it also adapts to your viewport and device. </p> <p> To see the difference between static and fixed top navbars, just scroll. </p> <p> <a class="btn btn-lg btn-primary" href="../../components/#navbar" role="button" >View navbar docs »</a > </p> </jumbotron> </div> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script> <script type="text/javascript" src="./javascripts/bootstrap.min.js"></script> <script type="text/javascript" src="./javascripts/riot+compiler.min.js" ></script> <script type="riot/tag" src="./components/jumbotron.tag"></script> <script> riot.mount("jumbotron"); </script>
当然これだけだと何の旨味もありません。次は<nav>
に書かれたグローバルナビゲーションもコンポーネント化してみましょう。
まずは何も考えず html をコピペして.tag
を作ってしまいます。名前はglobal-nav.tag
とかでいいでしょう。
次はカスタムタグのマウントです。マウントする要素が増えてきたらriot.mount('*')
で一括指定してしまいましょう。
<body> <global-nav></global-nav> <div class="container"> <jumbotron> <h1>Navbar example</h1> <p> This example is a quick exercise to illustrate how the default, static and fixed to top navbar work. It includes the responsive CSS and HTML, so it also adapts to your viewport and device. </p> <p> To see the difference between static and fixed top navbars, just scroll. </p> <p> <a class="btn btn-lg btn-primary" href="../../components/#navbar" role="button" >View navbar docs »</a > </p> </jumbotron> </div> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script> <script type="text/javascript" src="./javascripts/bootstrap.min.js"></script> <script type="text/javascript" src="./javascripts/riot+compiler.min.js" ></script> <script type="riot/tag" src="./components/jumbotron.tag"></script> <script type="riot/tag" src="./components/global-nav.tag"></script> <script> riot.mount("*"); </script> </body>
随分すっきりしましたね。すっきりしすぎてむしろちょっと不安です。
ですが、これで<global-nav>
を指定するだけでどんなページでもグローバルナビゲーションを呼び出すことが可能になりました。
ついでに<nav>
の中に書かれていた色んな要素もコンポーネント化してしまいましょう。一つの.tag
ファイルに複数のカスタムタグを定義できるので、黙々と分解してしまいます。
<global-nav> <nav class="navbar navbar-default navbar-fixed-top"> <div class="container"> <nav-header></nav-header> <navbar></navbar> </div> </nav> </global-nav> <nav-header> <div class="navbar-header"> <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar" > <span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> <a class="navbar-brand" href="#">Project name</a> </div> </nav-header> <navbar> <div id="navbar" class="navbar-collapse collapse"> <ul class="nav navbar-nav"> <li class="active"><a href="#">Home</a></li> <li><a href="#about">About</a></li> <li><a href="#contact">Contact</a></li> </ul> </div> </navbar>
カスタムタグの中にロジックを持たせる [diff]
困ったことに、このまま使っても<navbar>
の.active
が一切変わらず使い物になりません。
普通ならページごとに.active
を適用する場所だけを変えればいいんですが、せっかくコンポーネント化したのにそれはダサすぎです。自動でやってもらえるようにしましょう。
<navbar> <div id="navbar" class="navbar-collapse collapse"> <ul class="nav navbar-nav"> <li each="{ nav in navs }" class="{ active: isActive(nav.link) }"> <a href="{ nav.link }">{ nav.title }</a> </li> </ul> </div> <script> this.navs = [ { link: "index.html", title: "Home" }, { link: "about.html", title: "About" }, { link: "contact.html", title: "Contact" } ]; this.isActive = function(path) { return path == location.pathname.replace(/^\//, ""); }; </script> </navbar>
Riot.js ではカスタムタグ自身が持つ JavaScript のオブジェクトを自由にバインディング可能です。*7
また、if
やeach
を使うことで要素の表示/非表示やループを簡単に定義することができます。
カスタムタグ内にのみ適用されるスタイルを定義する [diff]
ある日、不意に「グローバルナビゲーションの.active
の背景色だけ変えたいな…」と思う日が来るかもしれません。*8ちゃんとコンポーネント化してあれば簡単にできます。カスタムタグ内で<style scoped>
を定義しましょう。*9
<global-nav> <nav class="navbar navbar-default navbar-fixed-top"> <div class="container"> <nav-header></nav-header> <div id="navbar" class="navbar-collapse collapse"> <navbar></navbar> <ul class="nav navbar-nav navbar-right"> <li class="active"><a href="./">scope test</a></li> </ul> </div> </div> </nav> </global-nav> <navbar> <ul class="nav navbar-nav"> <li each="{ nav in navs }" class="{ active: isActive(nav.link) }"> <a href="{ nav.link }">{ nav.title }</a> </li> </ul> <style scoped> /* bootstrapの指定が複雑すぎるので!importantで強制的に上書き */ .active > a { background-color: #bbdefb !important; } </style> <script> this.navs = [ { link: "index.html", title: "Home" }, { link: "about.html", title: "About" }, { link: "contact.html", title: "Contact" } ]; this.isActive = function(path) { return path == location.pathname.replace(/^\//, ""); }; </script> </navbar>
ついでに本当にカスタムタグ内だけで完結しているかすぐわかるようscope test
なるものも<global-nav>
に追加しておきました。scoped
を削除するとそっちも変な青色になるのがわかると思います。
また、「ある特定のルールだけは scoped にして、他は全体に適用できるようにしたい」と思ってしまった人のために、:scope
擬似クラスなんてものも用意されています。
正直デフォルトで全部scoped
にしておいてほしいような気もします*10が、まぁ妙な事故を起こさないよう適切に指定してあげてください。
ちょっと応用的な使い方
なんとなく使い方はわかってきたと思うので、もう少し応用的な使い方も説明していきましょう。
次はこっちのサンプルを Riot.js で書きなおしていきます。
呼び出し元からのパラメータを設定 [diff]
まずはブログの内容部分をコンポーネント化してみますか。
元々はこんな感じでした。
<div class="blog-post"> <h2 class="blog-post-title">Sample blog post</h2> <p class="blog-post-meta">January 1, 2014 by <a href="#">Mark</a></p> <!-- 本文が書かれているだけなので省略 --> </div>
カスタムタグにぶち込みます。ついでに CSS も持ってきてしまいましょう。ここ以外で使わなそうだし。
<blog-post> <div class="blog-post"> <yield/> </div> <style scoped> .blog-post { margin-bottom: 60px; } .blog-post-title { margin-bottom: 5px; font-size: 40px; } .blog-post-meta { margin-bottom: 20px; color: #999; } </style> </blog-post> </blog-post>
せっかくコンポーネント化したんだから、タイトルや投稿日、投稿者なんかはできれば外部からパラメータとして欲しいですね。当然できます。
カスタムタグはこんな感じに定義します。
<blog-post> <div class="blog-post"> <h2 class="blog-post-title">{ opts.title }</h2> <p class="blog-post-meta">{ opts.meta } by <a href="{ opts.link }">{ opts.author }</a></p> <yield/> </div> <style scoped> .blog-post { margin-bottom: 60px; } .blog-post-title { margin-bottom: 5px; font-size: 40px; } .blog-post-meta { margin-bottom: 20px; color: #999; } </style> </blog-post>
呼び出し元はこんな感じです。
<blog-post title="Sample blog post" meta="January 1, 2014" author="Mark" link="#" > <!-- 本文なので省略 --> </blog-post>
見てもらえればわかる通り、HTML の属性として指定したものはカスタムタグ内でopts
から取得することができます。どこでopts
を使ってどこで<yield/>
を使うかは設計者の腕の見せどころです。
また、opts
に渡す情報はタグのマウント時に動的に指定することもできます。*11
<script> riot.mount("blog-post", { title: "Sample blog post", meta: "January 1, 2014", author: "Mark", link: "#" }); </script>
サーバからの情報と連携 [diff]
「ブログの内容は全部 JSON に書かれていて、それを読み込んで表示する」みたいなパターンをやってみましょう。
まずはデータの準備です。どこかの何かの API 叩くと、こんな感じの JSON 配列が来るとします。
[ { "title": "Sample blog post", "meta": "January 1, 2014", "author": "Mark", "link": "#", "body": "<p>This blog post shows a few different types of content that's supported and styled with Bootstrap. Basic typography, images, and code are all supported.</p>\n<hr>\n<p>Cum sociis natoque penatibus et magnis <a href=\"#\">dis parturient montes</a>, nascetur ridiculus mus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis. Cras mattis consectetur purus sit amet fermentum.</p>\n<blockquote>\n<p>Curabitur blandit tempus porttitor. <strong>Nullam quis risus eget urna mollis</strong> ornare vel eu leo. Nullam id dolor id nibh ultricies vehicula ut id elit.</p>\n</blockquote>\n<p>Etiam porta <em>sem malesuada magna</em> mollis euismod. Cras mattis consectetur purus sit amet fermentum. Aenean lacinia bibendum nulla sed consectetur.</p>\n<h2>Heading</h2>\n<p>Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>\n<h3>Sub-heading</h3>\n<p>Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.</p>\n<pre><code>Example code block</code></pre>\n<p>Aenean lacinia bibendum nulla sed consectetur. Etiam porta sem malesuada magna mollis euismod. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa.</p>\n<h3>Sub-heading</h3>\n<p>Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean lacinia bibendum nulla sed consectetur. Etiam porta sem malesuada magna mollis euismod. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.</p>\n<ul>\n<li>Praesent commodo cursus magna, vel scelerisque nisl consectetur et.</li>\n<li>Donec id elit non mi porta gravida at eget metus.</li>\n<li>Nulla vitae elit libero, a pharetra augue.</li>\n</ul>\n<p>Donec ullamcorper nulla non metus auctor fringilla. Nulla vitae elit libero, a pharetra augue.</p>\n<ol>\n<li>Vestibulum id ligula porta felis euismod semper.</li>\n<li>Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.</li>\n<li>Maecenas sed diam eget risus varius blandit sit amet non magna.</li>\n</ol>\n<p>Cras mattis consectetur purus sit amet fermentum. Sed posuere consectetur est at lobortis.</p>" } // 以下、同じようなものなので省略 ]
この配列をeach
でぐるぐる回し、先ほどの<blog-post>
に表示させたいですね。こんな感じに。
<blog-main> <div class="col-sm-8 blog-main"> <blog-post each="{ post in posts }" title="{ post.title }" meta="{ post.meta }" author="{ post.author }" link="{ post.link }" body="{ post.body }" > </blog-post> <nav> <ul class="pager"> <li><a href="#">Previous</a></li> <li><a href="#">Next</a></li> </ul> </nav> </div> <style scoped> .blog-main { font-size: 18px; line-height: 1.5; } </style> <script> var self = this; this.posts = []; // API作るの面倒なのでJSON読みこむだけ $.get("/javascripts/blog.json").done(function(data) { self.posts = data; self.update(); }); </script> </blog-main> <blog-post> <div class="blog-post" name="body"> <h2 class="blog-post-title">{ opts.title }</h2> <p class="blog-post-meta"> { opts.meta } by <a href="{ opts.link }">{ opts.author }</a> </p> </div> <style scoped> .blog-post { margin-bottom: 60px; } .blog-post-title { margin-bottom: 5px; font-size: 40px; } .blog-post-meta { margin-bottom: 20px; color: #999; } </style> <script> // opts.bodyをそのまま表示するとエスケープされてしまうので、何らかの要素のinnerHTMLに追加しないといけない。 // カスタムタグ内のnameが付いているDOMはthisからアクセスできるので、適当にdivにbodyなんてnameを指定している。 $(this.body).append($(opts.body)); </script> </blog-post>
ここでのキモは JSON を Ajax で取得した後のself.update()
です。確かに Riot.js はバインディング機構を持ってはいますが、厳密に Observable なわけではありません。カスタムタグ外のコンテクスト*12で何らかの値を更新した場合はちゃんと通知してあげる必要があります。*13
まとめ
本当はもっと落とし穴的な要素も紹介したかったんですが、あまりにも長くなってしまった*14ので一旦切ります。
続きを書けるのはいつになるんでしょうね。1 ヶ月後とかですかね。
参考
*1:私が Riot.js を気に入ったのはこれをはっきり言い切ったところが一番大きいです。
*2:今 Polymer ってどうなってるんでしょうね?React 以降全然話を聞かなくなってしまいましたが。
*3:と言うか、「コンポーネント」を謳っている以上、何らかの形で動的に表示するデータを差し替えられなきゃ使えたもんじゃない。
*4:実際、Riot.js 側の公式ガイドでは Vue.js の話は全くしていない。
*5:かなり頑張ってるとは思いますが…。
*6:私が最近やった仕事もひたすら「Bootstrap のせいでネストが深くなるところを Riot.js で書き直す」でした。
*7:script タグは省略可能ですが、個人的には唐突感があってあまり好きじゃないのでちゃんと書いてます。
*8:来ないと思う。
*9:なお、style scoped = Scoped CSS 自体は Riot.js 独自の機能ではなく、ちゃんとした HTML の仕様です。(実装が進んでいるとは言っていない) この記事で詳しく解説されているので読んでおきましょう。
*10:全体に適用するなら普通に.css ファイル書けばいいし…。
*11:ちなみに、マウント時に動的指定しつつ属性値で渡している場合は属性値で渡した値が優先される。
*12:めちゃめちゃ簡単に言うと、this がカスタムタグ以外を指している状態
*13:公式にObserverがあるように見えますが、これはどちらかと言うとカスタムタグ同士の Pub/Sub を実現するためのものです。
*14:サンプルがデカすぎるのが悪い。
【Android】【Retrofit】Retrofit 2.0.1使い方メモとハマりどころメモ
2018/11/29 追記
前書き
最近Retrofitを使うことがあったんですが、イントロダクションをちょろっと読んだぐらいだと「え、そーなの?」と思うような事象に何度も見舞われたので、メモしておきます。
API の設定
HTTP メソッドやパス、クエリやパラメータなどを適当に作ったインターフェースとアノテーションで表現します。
public interface GitHubService { @GET("users/{user}/repos") Call<List<Repo>> listRepos(@Path("user") String user); }
ベースとなる URL はRetrofit.Builder#baseUrl
で渡すので、相対パスを書いておけば OK です。
アノテーションだけでどうにでもなってしまうので、色んなサービスの API を叩きたい時に共通の型がなくて却って使いにくいこともたまーにありますが、その辺は自分で共通の型を作ればいいだけの話です。
HTTP Methods
HTTP のメソッドに対応するアノテーションは以下のものが用意されています。必要十分って感じですね。
PATCH
ってDELETE
以上に見たことないけど、意識の高い API 設計者は使うんでしょうか。
独自のメソッドを用意している意識が高すぎる API に対してはHTTP#method
に渡しましょう。
あと、微妙にハマりポイントなんですが、HEAD
を指定したメソッドの返り値の型は必ずCall<Void>
にする必要があります。*1
普通にヘッダだけ欲しいんだけどどうしたらええの?と言う質問が Issue にあるんですが、ガン無視されています。私はもう面倒なので GET メソッド投げるようにしてしまいました。(怒)
ルーティング
@GET("users/{user}/repos")
の{user}
部分をメソッドの引数で渡すことができます。
対応する引数にPath
アノテーションをつけてあげれば OK です。
マルチバイト文字とか記号とかをうにゃうにゃしたい時はPath#encoded
にtrue
を渡してあげましょう。
クエリ
Query
アノテーションを指定してあげればよしなにやってくれます。
@GET("group/{id}/users") Call<List<User>> groupList(@Path("id") int groupId, @Query("sort") String sort);
optional なパラメータが多い時はQueryMap
アノテーションを使うと便利です。
@GET("group/{id}/users") Call<List<User>> groupList(@Path("id") int groupId, @QueryMap Map<String, String> options);
@Query
も@QueryMap
もencoded
にtrue
を指定することで URL エンコードできるので、特に理由がないならやっておいた方が色々安全だと思います。
また、うっかりミスでこういうことをやってしまうことがあるんですが、これは怒られます。
// クエリ部分にPathで値を渡すのは無理 // 渡せるようにしても良い気がするけどね。 @GET("group/{id}/users?sort={sort}") Call<List<User>> groupList(@Path("id") int groupId, @Path("sort") String sort);
クエリ応用編
@Query
を指定する引数を配列 / 可変長引数にすることで、同じクエリを複数指定することができます。
@GET("/list") Call<ResponseBody> list(@Query("category") String... category);
例えば、service.list("hoge", "fuga")
と呼び出したら、/list?category=hoge&category=&fuga
になります。*2
また、特定のクエリを固定値で指定しておく場合はこのように書けば OK です。うまいこと後ろにクエリを足してくれます。
@GET("group/{id}/users?sort=desc") Call<List<User>> groupList(@Path("id") int groupId, @Query("filter") String filter);
クエリ更に応用編(外法)
GET
だけでなくどのメソッドでもいいんですが、Url
アノテーションを引数として渡すことで API のパス自体を動的に変更できます。
public interface GitHubService { @GET Call<List<Repo>> listRepos(@Url String url); }
極端な話、この@Url
にパスもクエリもぶち込んでしまえば動いてくれます。型安全とは何だったのか。
POST のパラメータ
多分、PUT
でも同じだと思います。
パラメータとして渡すにはBody
かField
(もしくはFieldMap
)アノテーションを使います。
application/x-www-form-urlencoded
形式での POST
application/x-www-form-urlencoded
(key=value) 形式に自動で変換して欲しい場合は@FormUrlEncoded
+@Filed
を使います。
@FormUrlEncoded @POST("user/edit") Call<User> updateUser(@Field("first_name") String first, @Field("last_name") String last);
デフォルト値を設定する方法は現在開発中なので、@FieldMap
と適当なデフォルト値を持つMap<String, String>
を作るメソッドを併用するぐらいしか対処法がありません。
@FormUrlEncoded @POST("user/edit") // Call<User> updateUser(@Field("first_name") String first, @Field("last_name") String last); Call<User> updateUser(@FieldMap Map<String, String> names);
public static Map<String, String> createFullName(String first) { final Map<String, String> param = new HashMap<>(); param.put("first_name", first); param.put("last_name", "hogehoge"); return param; }
それ以外の形式での POST
@Body
を使うとそのまんま Body 部に値をぶちこみます。application/json
などを渡したい場合はこちらを使いましょう。
ちなみにConverter
(後述)を指定することで独自の型でもいい感じに変換してくれます。*3
@POST("users/new") Call<User> createUser(@Body User user);
UTF-8 以外でエンコードしたapplication/x-www-form-urlencoded
@FormUrlEncoded
はパラメータを強制的に UTF-8 でエンコードしてしまいます。この文字コードを変えることはできません。*4
どうしても他の文字コードで POST する必要がある場合も@Body
を使う必要があります。
Converter
を自作してもいいですが、スカラー値を扱う Converterが公式で用意されているので、自分でapplication/x-www-form-urlencoded
形式のString
を作ってしまうのが早いです。*5
// API定義 @POST //@FormUrlEncoded //Call<User> updateUser(@Field("first_name") String first, @Field("last_name") String last); Call<User> updateUser(@Body String body);
// 呼び出し方 String firstName = "山田"; String lastName = "太郎"; Function<String, String> encode = (s) -> { try { // java.net.URLEncoderでUTF-8以外の文字コードに変換 return URLEncoder.encode(s, "Shift-JIS"); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e); } }; // 自分でapplication/x-www-form-urlencoded形式のStringを作る String body = String.format("%s=%s&%s=%s", encode.apply("first_name"), encode.apply(firstName), encode.apply("last_name"), encode.apply(lastName)); // StringをRequestBodyとして投げる Call<User> user = service.updateUser(body);
Multipart なデータを POST
渡す値はPart
(もしくはPartMap
)アノテーションで指定します。
@Multipart @PUT("user/photo") Call<User> updateUser(@Part("photo") RequestBody photo, @Part("description") RequestBody description);
渡せる型はデフォルトだとokhttp3.MultipartBody.Part
かokhttp3.RequestBody
で固定されているようです。もちろん対応するConverter
があれば独自の型でも OK です。*6
なお、@Multipart
と@FormUrlEncoded
は併用できません。Content-Type
が矛盾するからです。*7
ヘッダの指定
Headers
アノテーションを指定しておくことで、事前にヘッダを作っておくことができます。
@Headers("Cache-Control: max-age=640000") @GET("widget/list") Call<List<Widget>> widgetList();
@Headers({ "Accept: application/vnd.github.v3.full+json", "User-Agent: Retrofit-Sample-App" }) @GET("users/{username}") Call<User> getUser(@Path("username") String username);
また、Header
アノテーションを引数に指定することで、動的にヘッダ部を変更することができます。
@GET("user") Call<User> getUser(@Header("Authorization") String authorization)
ただ、全 API 共通で指定したいヘッダはokhttp3.Interceptor
を作ったほうがいいです。(後述)
実際の通信部分の設定
Retrofit.Builder#client
で指定するokhttp3.OkHttpClient
が実際に HTTP 通信を行うクライアントになります。
どんな設定ができるかはokhttp3.OkHttpClient.Builder
のメソッドを見れば大体わかると思います。また、Wiki の Recipesもかなり参考になると思います。
Proxy や Timeout、Cookie / キャッシュの管理もこれでやることになるので、Retrofit を使う場合は必ず一読しておいたほうがいいです。
ちなみにOkHttp3 ではOkHttpClient
のコネクションプールを各インスタンス毎に持っているのでシングルトンで持っておくことが推奨されています。Interceptor
やタイムアウト値、リトライ回数なんかを API 別に設定したい場合はokhttp3.OkHttpClient#newBuilder
を使いましょう。
okhttp3.Interceptor
の設定
下手なコードを読むよりWikiを読んだほうがわかりやすいです。
okhttp3.Interceptor
を実装するにあたって覚えておくことは以下の 3 つだけです。
chain#request()
で発行する直前のokhttp3.Request
を取得okhttp3.Request#newBuilder()
でリクエストの上書きchain#proceed(Request)
でリクエストを発行
okhttp3.Response
も色々さわりたい*8場合はproceed
で取得した内容をごにょごにょしましょう。
単純に共通のヘッダを指定したいだけならこんな感じになります。複数のヘッダを指定したい場合はokhttp3.Headers
を使ったりしましょう。
public class CommonInterceptor implements Interceptor { @Override public Response intercept(Interceptor.Chain chain) throws IOException { return chain.proceed( chain.request().newBuilder() .header("User-Agent", "OkHttp Example") .build() ); } }
Cookie の設定
okhttp3.CookieJar
を適当に実装してあげましょう。
一ミリも頭を使わない実装をするとこんな感じになります。ただ、あまりにも Cookie の仕様をガン無視しているのでおすすめできません。
public class NoHeadCookieJar implements CookieJar { private final List<Cookie> saveCookies = new ArrayList<>(); @Override public void saveFromResponse(HttpUrl url, List<Cookie> cookies) { for(Cookie c : cookies) { if(!saveCookies.contains(c)) saveCookies.add(c); } } @Override public List<Cookie> loadForRequest(HttpUrl url) { return saveCookies; } }
きちんと Cookie を管理するのであればJavaNetCookieJar
を使用するのが安全です。
JavaNetCookieJar
は Cookie をCookieHandler
(=CookieManager
)で管理するので、他の HTTP クライアントや、Android であればWebView
と Cookie を共有しやすくもなります。
ちなみにokhttp
本体とは別モジュール*9なので、依存関係に追記が必要なことを忘れないでおきましょう。
dependencies {
compile "com.squareup.okhttp3:okhttp-urlconnection:3.2.0"
}
今回は詳しく説明しませんが、同じくokhttp-urlconnection
内にあるJavaNetAuthenticator
も中々便利そうなので、Retrofit or okhttp でプロキシ認証が必要になったら参考にしたり流用したりしましょう。
レスポンスの Call Adapter
Retrofit 2.x は、デフォルトのままだとレスポンスを必ずCall<T>
でラップするようになっています。*10
Call<T>
には同期的に実行するexecute
と非同期的に実行するenqueue
の 2 つのメソッドが用意されています。
enqueue
で渡すCallback<T>
は確かに必要最低限の機能が用意されていますが、Promise パターンが使えないので、複数の API を組み合わせて実行しようとすると JavaScript もびっくりなコールバック地獄になります。
それは面倒だよね、ってことで、もっと便利なものにラップできるよう、Call Adapterが用意されています。
ライブラリ | ラップする型 | モジュール |
---|---|---|
RxJava 1.x | Observable / Single |
com.squareup.retrofit2:adapter-rxjava |
RxJava 2.x | Observable / Flowable / Single / Maybe / Completable |
com.squareup.retrofit2:adapter-rxjava2 |
Guava | ListenableFuture |
com.squareup.retrofit2:adapter-guava |
Java 8 | CompleteableFuture |
com.squareup.retrofit2:adapter-java8 |
実際にどの Call Adapter を使うかはRetrofit.Builder#addCallAdapterFactory
で指定します。
RxJava 1.x の Adapter を指定するならこんな感じです。
Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://api.example.com") .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .build();
なお、RxJavaCallAdapterFactory
にはRxJavaCallAdapterFactory#createWithScheduler(Scheduler)
と言うメソッドも用意されています。ここで指定しておけば生成されるObservable
に対して自動でsubscribeOn
してくれるので、上手く活用しましょう。*11
リクエスト・レスポンスの自動型変換
HTTP 通信によって取得した内容(Entity)は事前にConverter
を指定しておくことで自動で型変換を行うことができます。*12
公式に用意されているものは以下の通りです。
ライブラリ | モジュール |
---|---|
Gson | com.squareup.retrofit2:converter-gson |
Jackson | com.squareup.retrofit2:converter-jackson |
Moshi | com.squareup.retrofit2:converter-moshi |
Protobuf | com.squareup.retrofit2:converter-protobuf |
Wire | com.squareup.retrofit2:converter-wire |
Simple XML | com.squareup.retrofit2:converter-simplexml |
Scalars (primitives, boxed, and String) | com.squareup.retrofit2:converter-scalars |
使用する Converter はRetrofit.Builder#addConverterFactory
で指定することができます。Call Adapter と一緒ですね。
Gson の Converter を指定する場合はこんな感じになります。
Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://api.github.com") .addConverterFactory(GsonConverterFactory.create()) .build();
HTTP 通信結果の取得
ステータスコードやヘッダといった情報をコールバックで取得したい場合は、返り値の型をResponse<T>
でラップしましょう。(ex. Call<Response<String>>
)
ちなみにResponse<T>
は Converter に一切依存しないので、どんな Converter を指定していても使用できます*13
また、戻り値の型をokhttp3.ResponseBody
にすることで、生の Entity や Body を取得することもできます。(ex. Call<ResponseBody>
)
こちらもConverter に一切依存しないので、「欲しい Converter が用意されてないけど自力で実装するのダルい」「型変換のところでエラーが出るから値を確認しつつデバッグしたい」などの場合に使いましょう。*14
まとめ
またなんか無駄に濃密な記事になってしまいました。
*1:それ以外の型を指定すると「HEAD method must use Void as response type」と怒られる
*2:@QueryMap で同じようなことができるかどうかは未検証。Key が同じになっちゃうから難しいような…?
*3:Retrofit 1.x では勝手に JSON 形式にしていたようだが、今はちゃんと Converter を指定してやる必要がある。
*4:現在の application/x-www-form-urlencoded の仕様的に仕方ないんですが…。
*5:Converter を自作しても結局同じようなことをする必要がある。
*6:Introductionで紹介されている Converter にはそれ用のものがないので、余程のことがないなら MultipartBody.Part か RequestBody でいい気がする。
*7:と、そんな回答がされている。まぁ、サーバ側の実装が悪いとしか言いようがないっちゃないよね。
*8:本来返されるレスポンスの内容をここで書き換えたりするのは行儀が悪い(と言うか、Retrofit においてそれは Converter のお仕事である)ので、各情報の取得だけにとどめておいた方が無難。
*9:なんでやねん。
*10:1.x では違ったらしいけど割愛。
*11:特に Android では「呼び出し元で subscribeOn し忘れた」のようなしょうもない事故を防止するためになるべくこっちを使うべき。
*12:レスポンスだけでなく、リクエストで使う引数の型も Converter で変換できる。
*13:もちろん一つも Converter を指定していなくても使用可能。
*14:Converter.Factoryの実装はどうしても「どんな型が指定されても大丈夫!」って感じにせざるを得ないので、しょうもない Converter を大量生産するよりはコールバック内でそれぞれ型変換した方がまだマシな気がする。(個人の感想です。)
【Android】Android の Data Binding でできること(基本編)
2018/12/01 追記
- シンタックスハイライトを適用しました。
- こんな場末の技術ブログを見るぐらいなら公式のドキュメントをちゃんと読んだほうがいいと思います。
- 記事公開当時は大した情報が記載されていませんでしたが、今はかなり充実しています。
前書き
最近(と言っても 1 ~ 2 ヶ月前の話ですが)、訳あって携帯を機種変更し、Android 5.1.1 の端末を手に入れました。
いい機会なので開発環境も一新し、Eclipse + ADT とか言うファッキンな環境を捨てて Android Studio + Gradle + Kotlin に切り替えました。
IntelliJ の Lombok プラグインが@ExtensionMethod
に対応していれば Kotlin は恐らく触りもしなかったんですが、Scala をちょっと触っていたこともあってか、中々快適に開発できています。普段の業務もこれで書きたいぐらいです。
Kotlin の話はいずれするかもしれないし、しないかもしれませんが、今回は Android Studio 1.3 からサポートされ始めたData Bindingの話をします。
Data Binding とは?
レイアウト用の XML(/res/layout/
内のやつ)にデータを定義することで、そのデータのプロパティに直接アクセスできるようになりました。
以下のコードを見ればなんとなくわかると思います。(コードはドキュメントのサンプルそのままです。)
public class User { private final String firstName; private final String lastName; public User(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public String getFirstName() { return this.firstName; } public String getLastName() { return this.lastName; } }
<!--?xml version="1.0" encoding="utf-8"?--> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="user" type="com.example.User"/> </data> <LinearLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.firstName}"/> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.lastName}"/> </LinearLayout> </layout>
なんか別の言語で GUI のアプリを作るときも似たようなやつを必死に書いた気がするんですが、きっと気のせいでしょう。と言うか、XAML よりもシンプル(当社比)でいい感じです。Java だとわざわざ getter を作らなきゃいけないからそこで帳消しですよ。
実際にデータをセットする時はこんな感じに呼び出します。
main_activity.xml
であれば、MainActivityBinding
と言うクラスが自動で生成されます。
public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity); User user = new User("Test", "User"); binding.setUser(user); } }
プラグインの導入
Android Plugin for Gradle の1.5.0-alpha1
からプラグインが統合されたため、基本的には module レベルのbuild.gradle
にdataBinding { enabled = true }
を追加するだけで OK です。
android { // ... dataBinding { enabled = true } }
何らかの事情で古いものを使っている人は別途プラグインを入れる必要があります。まずは root のbuild.gradle
内のdependencies
に DataBinder を追加します。
最新版がどれかわからない、って人はここにあるので適当に見繕ってきてください。
buildscript { repositories { jcenter() mavenCentral() } dependencies { // ... classpath "com.android.databinding:dataBinder:1.0-rc4" } }
次に module レベルでのbuild.gradle
に設定を入れていきます。と言っても、apply plugin: 'com.android.databinding'
を突っ込むだけです。
apply plugin: 'com.android.databinding' android { // ...
データオブジェクト
プロパティの定義
Java のフィールドをプロパティとして呼び出すためには、以下の二条件のうちどちらか一つを満たしていれば OK です。
- public なフィールドである
- getter が定義されている
具体的にはこんな感じですね。
public class User { public final String firstName; public final String lastName; public User(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } }
public class User { private final String firstName; private final String lastName; public User(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public String getFirstName() { return this.firstName; } public String getLastName() { return this.lastName; } }
データ変更の検知
上記の例だとデータを変更してもView
の表示は変わりません。
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity); User user = new User("Test", "User"); binding.setUser(user); // firstNameの値を変更してもTextViewの表示は変わらない user.setFirstName("Hoge") }
データが変わったら自動でView
の表示も変わって欲しい人もいると思います。そんな人のためにいくつかの手段が用意されています。
まずはObservableFields
を使用する例です。
public class User { private final ObservableField <string> firstName = new ObservableField<string>(); private final ObservableField<string> lastName = new ObservableField<string>(); public User(String firstName, String lastName) { this.firstName.set(firstName); this.lastName.set(lastName); } public ObservableField<string> getFirstName() { return this.firstName; } public ObservableField<string> getLastName() { return this.lastName; } }
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity); User user = new User("Test", "User"); binding.setUser(user); // firstNameの値を変更するとTextViewの表示も変わる user.getFirstName.set("Hoge"); }
うーん、このダサさ。ちなみにプリミティブ型のためにObservableBoolean
とかObservableInt
なんてのがそれぞれも用意されてます。一秒でも早く Java が絶滅してくれないかなと思ったことでしょう。
他にもObservable なMap
やList
の実装方法なんかもありますが、「XML に実体参照を書かないといけないの、流石にダサすぎでは?」ぐらいしか言うことないので、必要なら読んどいてください。
もうちょっとスマートな方法としてBaseObservable
を継承する方法があります。
public class User extends BaseObservable { private final String firstName; private final String lastName; public User(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } @Bindable // 変更を通知したいプロパティには@Bindbleをつける public String getFirstName() { return this.firstName; } @Bindable public String getLastName() { return this.lastName; } public void setFirstName(String firstName) { this.firstName = firstName; // @Bindableがついているプロパティは // BR.プロパティ名と言うフィールドが自動で生成される notifyPropertyChanged(BR.firstName); } public void setLastName(String lastName) { this.lastName = lastName; notifyPropertyChanged(BR.lastName); } }
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity); User user = new User("Test", "User"); binding.setUser(user); // firstNameの値を変更するとTextViewの表示も変わる user.setFirstName("Hoge"); }
implements
じゃなくてextends
なのがキツいところですね。
本当はBaseObservable
ではなくObservable
を実装すればいいみたいなんですが、当然通知の仕組みは自力で実装する必要があります。馬鹿にしていらっしゃるのかな?(敬語)
でもまぁ、ソースを読む限り大した実装ではないので、必要なら自力で実装しちゃってもいいかもです。
また、どうしても setter に追記が必要なので、lombok を使っている人からすると二倍しんどいです。勘弁して欲しいですね。
イベントのバインド
android:onClick
のように何らかの Listener を設定できる場所にはイベントをバインドすることができます。
public class User { public final String firstName; public final String lastName; public User(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public void showFullName(View v) { Toast.makeText(v.getContext(), this.firstName + " " + this.lastName, Toast.LENGTH_SHORT).show(); } }
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="user" type="com.example.User"/> </data> <LinearLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.firstName}"/> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.lastName}"/> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="show fullname" android:onclick=@{user.showFullName} /> </LinearLayout> </layout>
また、本来なら定義されていないはずのandroid:onLongClick
と言う attr にイベントをバインドすることもできます。理由は後述。
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="user" type="com.example.User"/> </data> <LinearLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.firstName}"/> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.lastName}"/> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="show fullname" android:onLongClick=@{user.showFullName} /> <!-- android:onLongClick --> </LinearLayout> </layout>
@BindingMethod
と@BindingAdapter
独自のイベントを設定するには@BindingMethod
と@BindingAdapter
と言う黒魔術アノテーションを駆使する必要があります。
と言うか、先ほどの例で挙げたandroid:onclick
もandroid:onLongClick
も@BindingMethod
でやってるだけです。この 2 つのアノテーションは既存のView
だろうがなんだろうが attr を拡張することができます。
公式ドキュメントを読むと@BindingMethod
はsetter をリネームしたい時、@BindingAdapter
は独自の setter を定義したい時に使うといいよと紹介されています。
つまり、目的のView
が既に対応するようなメソッドを持っている時は@BindingMethod
を、完全に自分で定義(拡張)したい時は@BindingAdapter
を使ってね、ぐらいのイメージでしょうか。
@BindingMethod
の使用例
せっかくだから先ほどの例がどのように定義されているのか実際のソースを見てみましょう。
と言っても関係あるとこだけしか抜き出しませんが。長いしね。
@BindingMethods({ @BindingMethod(type = View.class, attribute = "android:onClick", method = "setOnClickListener"), @BindingMethod(type = View.class, attribute = "android:onLongClick", method = "setOnLongClickListener"), }) public class ViewBindingAdapter { ...
上記の記述により、View
を継承しているクラスのandroid:onClick
に何らかのメソッドがバインドされた場合、View.setOnClickListener
へ処理を移譲することができます。中々クールですね。
ちなみに@BindingMethods
は@Target({ElementType.TYPE})
で定義されていますが、多分、本当はどこでもよかったんでしょうね…。
@BindingAdapter
の使用例
例えば、ImageView
に対してGlideをつかって画像をセットしたい時とかあると思います。
(公式ドキュメントはなぜかPicassoを例にしていますが、あえて競合他社のライブラリを例にした理由は謎です。)
面倒なので Model は超単純にしましょう。
public class Model { public final String imageUrl; public Model(String imageUrl) { this.imageUrl = imageUrl; } }
こんな形でバインドできたらありがたいです。名前空間はandroid
じゃなくてres-auto
なので注意しましょう。
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <data> <variable name="m" type="com.example.Model"/> </data> <LinearLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:imageUrl="@{m.imageUrl}" /> </LinearLayout> </layout>
わざわざImageView
を継承して独自のattr
を作らずともBindingAdapter
を使うことで簡単に実現できます。
public final class ImageViewExtensions { @BindingAdapter({"bind:imageUrl"}) public static void loadImage(ImageView v, String imageUrl) { Glide.with(v.getContext()).load(imageUrl).into(v); } }
「画像を読み込めたらそれを表示してほしいけど、失敗したらdrawable
の画像を表示したい」なんて要件も当然あると思います。
そんな時は@BindingAdapter
に複数の引数を渡せば OK です。
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <data> <variable name="m" type="com.example.Model"/> </data> <LinearLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:imageUrl="@{m.imageUrl}" app:error="@{@drawable/error}" /> </LinearLayout> </layout>
public final class ImageViewExtensions { @BindingAdapter({"bind:imageUrl", "bind:error"}) public static void loadImage(ImageView v, String imageUrl, Drawable error) { Glide.with(v.getContext()).load(imageUrl).error(error).into(v); } }
ただし、上記の場合だと必ずapp:imageUrl
とapp:error
の両者を指定する必要があります。
app:error
を指定しないこともあるかも…という場合は、@BindingAdapter
を指定するメソッドを 2 つ用意するしかありません。(ダサい)
public final class ImageViewExtensions { @BindingAdapter({"bind:imageUrl"}) public static void loadImage(ImageView v, String imageUrl) { Glide.with(v.getContext()).load(imageUrl).into(v); } @BindingAdapter({"bind:imageUrl", "bind:error"}) public static void loadImage(ImageView v, String imageUrl, Drawable error) { Glide.with(v.getContext()).load(imageUrl).error(error).into(v); } }
レイアウト XML の細かい文法
前述した内容だけでもある程度使えますが、レイアウト XML でもっと色んなことができるようになってるのでガンガン活用しましょう。
演算子
ちょっとした計算や bool 値の判定なんかもレイアウト XML 内でできるようになりました。
具体的に何ができるかは公式ドキュメントを読んでください。5 分もあれば読めますよ。
以下、基本的なコード例です。公式のやつそのまんまですが。
android:text="@{String.valueOf(index + 1)}"
android:visibility="@{age < 13 ? android.view.View.GONE : android.view.View.VISIBLE}"
android:transitionName='@{"image_" + id}'
View#VISIBLE
とView#GONE
の切り替えなんかは相当楽になりますね。
また、(pure な)Java にはまだないNull 合体演算子がサポートされています。
android:text="@{user.displayName ?? user.lastName}"
<!-- android:text="@{user.displayName != null ? user.displayName : user.lastName}" と同義 -->
Import
<data>
内に Import 文を書くことが可能です。
で?と思うかもしれませんが、Android SDK のフレームワーク内のあれこれを使うときとかに便利です。
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <import type="android.view.View"/> <variable name="user" type="com.example.User"/> </data> <LinearLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.firstName}" android:visibility="@{user.isAdult ? View.VISIBLE : View.GONE}"/> </LinearLayout> </layout>
package は違うけど同名のクラスを使う場合はalias
を設定できます。
<import type="android.view.View"/> <import type="com.example.real.estate.View" alias="Vista"/>
バインディングクラスのパッケージ / 名称変更
バインディングクラスってなんじゃい、って話ですが、例えばmain_activity.xml
であれば、MainActivityBinding
と言うクラスが自動で生成されます。このMainActivityBinding
がバインディングクラスです。
もし root パッケージがcom.example.my.app
だとしたら、MainActivityBinding
はcom.example.my.app.databinding
に配置されます。
何らかの理由があってこのデフォルトで生成されるクラスの名前や、生成されるパッケージを変更したい場合はdata
要素の attribute としてclass
を指定してあげれば OK です。
クラス名を変えるだけならこんな感じです。
<data class="MainActivityViewHolder"> ... </data>
パッケージも変更したいのであれば、フルパスで指定してあげましょう。
<data class="com.example.MainActivityViewHolder"> ... </data>
root パッケージ直下に置きたい場合は先頭に.
をつけてあげれば OK です。
<data class=".MainActivityViewHolder"> ... </data>
Include 先へのバインディング
Include したいレイアウトファイルにも要素をバインドできます。
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:bind="http://schemas.android.com/apk/res-auto"> <data> <variable name="user" type="com.example.User"/> </data> <LinearLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <include layout="@layout/name" bind:user="@{user}"/> <include layout="@layout/contact" bind:user="@{user}"/> </LinearLayout> </layout>
当然と言えば当然ですが、Include 先のレイアウトファイルでもちゃんとバインディングの設定をしておく必要があります。
上記の例で言えば、name.xml
とcontact.xml
内でuser
と言う variable がなければ叱られます。
また、<merge>
要素直下の include に対してはバインドできないそうなので気をつけましょうね。
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:bind="http://schemas.android.com/apk/res-auto"> <data> <variable name="user" type="com.example.User"/> </data> <!-- merge直下のincludeなのでこれはダメ --> <merge> <include layout="@layout/name" bind:user="@{user}"/> <include layout="@layout/contact" bind:user="@{user}"/> </merge> </layout>
暗黙の型変換
これまた黒魔術っぽいんですが…。本来なら適切でない型がバインドされても、@BindingConversion
が指定されているメソッドに移譲して型変換することができます。
ちょっと何言ってるかわかんないですよね。例えばこんな感じです。
<View android:background="@{isError ? @color/red : @color/white}" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
View.setBackground
はDrawable
しか受け付けないんですが、color
のリソース ID(int
)を渡したいとします。
当然このままでは怒られるので、ColorDrawable
に変換する static メソッドを作り、@BindingConversion
を指定します。
@BindingConversion public static ColorDrawable convertColorToDrawable(int color) { return new ColorDrawable(color); }
これでandroid:background
にint
が渡されたとしてもColorDrawable
に変換してくれます。
何も知らないと非常に混乱しそうなので、用量、用法を守ってお使いください…。と言うか、@BindingAdapter
と違って適用する attr を制限できないっぽいのでめちゃめちゃ危険です。使わない方がいいかもしれません。
ちなみに、Drawable
とint
のどちらかが返されるみたいなパターンは普通にダメです。
<View android:background="@{isError ? @drawable/error : @color/white}" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
とは言え、static メソッドは普通に呼べるのでそっちで回避できます。
<View android:background="@{isError ? @drawable/error : ColorExtensions.convertColorToDrawable(@color/white)}" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
…あれ?じゃあほんとにこれいらなくね?
まとめ
久しぶりに濃厚な記事になってしまいました。
基本編と銘打ってはいますが、応用編を書く予定とネタは今のところありません。
自分でも色々いじってみたテスト用プロジェクトをGitHubにおいておきました。それぞれ、こんな感じです。
- Data Binding の導入
- とりあえず適当にバインディングしてみる
- Observable なプロパティとイベントのバインディング
BaseObservable
を継承したパターン@MethodAdapter
で遊んでみる
参考
ぶっちゃけ公式ドキュメントが一番わかりやすかったです。
【NYAGOS】プロンプトで使える特殊文字と ANSI エスケープシーケンスを Lua でラップする
2018/12/01 追記
- シンタックスハイライトを適用しました。
前書き
前回の記事でプロンプトをごにょごにょしていたんですが、プロンプトだけで使える特殊文字だとか、ANSI エスケープシーケンスだとかをいちいち文字列で書くのが面倒になりました。
そもそもぱぱっと書けませんし読めませんよそんなもん。ゆとり教育では学びませんでした。
しかし、現代では教育だけでなくマシンの CPU やメモリもゆとりが出てきています。可読性を高めるため、全部 Lua でラップしてしまいましょう。
プロンプトだけで使える特殊文字
色々探していたらMSDNに一覧がありました。ここには転記しません。かわりに今回使うコードを載せます。
share.prompt = { eq='$q', -- equal '=' dollar='$$', -- dollar '$' time='$t', -- current time date='$d', -- current date path='$p', -- current path (with drive letter) ver='$v', -- windows version drive='$n', -- current drive letter gt='$g', -- greater than '>' lt='$l', -- less than '<' pipe='$b', -- pipe '|' crlf='$_', -- line break esc='$e', -- escape bs='$h', -- backspace amp='$a', -- ampersand '&' l_par='$c', -- left parenthesis '(' r_par='$f', -- right parenthesis ')' space='$s', -- space }
ちなみに$
の後の文字は大文字・小文字を区別しないそうです。
また、$h
(backspace)は$t
(time)とか$d
(date)とかでとってきた文字列を頑張って削る時に使うそうです。全くもってゆとりがありません。
ANSI エスケープシーケンス
ANSI エスケープシーケンス自体についてはこの記事が非常にわかりやすかったです。歴史的経緯や古き良き C 言語のサンプルもちょこっと書いてあるので、暇な人は読んでみましょう。
細かい一覧は英語版 Wikipediaを読むのが一番いいんですが、何をどれだけ読もうとも、Nyagos で使えるのはansicolorに書いてあるやつだけです。なので、ここだけラップします。
ANSI エスケープシーケンスのラップ
ANSI エスケープシーケンスはプロンプト以外でも普通に使えるので、先ほど作ったshare.prompt
テーブルにぶちこむのは流石に乱暴です。
別途share.escape_sequence
と言うテーブルを作ります。
share.escape_sequence = { -- attribute attr = { off='0', bold='1', bold_off='21', underline='4', underline_off='24', blink='5', blink_off='25' }, -- foreground fg = { black='30', red='31', green='32', yellow='33', blue='34', magenta='35', cyan='36', white='37', default='39', -- prefix 'l' is 'Light' l_gray='90', l_red='91', l_green='92', l_yellow='93', l_blue='94', l_magenta='95', l_cyan='96', l_white='97' }, -- background bg = { black='40', red='41', green='42', yellow='43', blue='44', magenta='45', cyan='46', white='47', default='49', -- prefix 'l' is 'Light' l_gray='100', l_red='101', l_green='102', l_yellow='103', l_blue='104', l_magenta='105', l_cyan='106', l_white='107' }, }
ANSI エスケープシーケンスの文法
厳密にはANSI エスケープシーケンスにおける色付けの文法ですが…。
基本的には\x1b[(該当するコード)m
と言う文字列を作成します。例えば、赤字にしたいなら\x1b[31m
ですね。
「ここからここまでの文字列に適応する」と言った指定はできないため、適用させた後は再度 ANSI エスケープシーケンスを作って元に戻す必要があります。(そのためにdefault
とかoff
と言った要素がある。)ちなみにこれを忘れるとその後の標準出力がずっと真っ赤になったりします。怖いですね。
また、複数の要素を一気に指定したい場合は\x1b[(該当するコード);(該当するコード);...m
と記述することができます。例えば、文字色は赤字、背景色は灰色、ついでに下線も引いてやろう、って場合は、\x1b[31;100;4m
となります。指定するコードの順番に制限はないです。(つまり、\x1b[4;100;31m
でも同じ効果を得られる。)
ANSI エスケープシーケンスを作成する関数
上記の内容を踏まえ、share.escape_sequence
に各コードのテーブルを渡すと ANSI エスケープシーケンスを作ってくれる関数を作ります。
share.escape_sequence = { -- ...省略... create_sequence = function(...) local attrs = {...} local joined_attrs = '' for n, v in pairs(attrs) do local val = tostring(v) if val ~= nil then joined_attrs = joined_attrs ~= '' and joined_attrs .. ';' .. val or val end end return string.format('\x1b[%sm', joined_attrs) end }
呼び出しはこんなイメージです。何をやっているのか大分わかりやすくなったと思いませんか?
local esc = share.escape_sequence -- colorに'\x1b[2;31;49m'が返ってくる local color = esc.create_sequence(esc.attr.bold, esc.fg.red, esc.bg.default)
実践
実践って言うか、単に私の.nyagos
の中身から抜粋しただけですが…。
share.org_prompter=nyagos.prompt nyagos.prompt = function(this) local prompt = share.prompt local esc = share.escape_sequence -- add dir name (with drive letter) local c_dir = esc.create_sequence(esc.attr.bold, esc.fg.red) local prompt_message = string.format('%s[%s]', c_dir, prompt.path) -- check git branch local git_branch_name = nyagos.eval('git rev-parse --abbrev-ref HEAD 2>nul') if (git_branch_name ~= '') then -- add git branch name local c_branch = esc.create_sequence(esc.fg.l_yellow) prompt_message = string.format(prompt_message .. ' %s[%s]', c_branch, git_branch_name) end -- add line break prompt_message = prompt_message .. prompt.crlf -- add dollar local c_dollar = esc.create_sequence(esc.fg.red) prompt_message = prompt_message .. c_dollar .. prompt.dollar -- add input command color local c_input_command = esc.create_sequence(esc.fg.white) prompt_message = string.format(prompt_message .. ' %s', c_input_command) return share.org_prompter(prompt_message) end
元々が冗長な仕組みなので仕方ないんですが、どうしても長くなってしまいますね。
まとめ
プロンプトの方はともかく、ANSI エスケープシーケンスの方は何かに応用できるかもしれません。(エラーメッセージに色を付ける、とか…。)
現状は全くのノーアイデアです。何か思いついたらまた作りましょう。