Umi Uyuraのブログ

プログラミング関連の作業ログ

Alloy Modelの学習・その4(REST API)

引き続き、Alloy Modelを勉強中。

umi-uyura.hatenablog.com

クロスプラットフォームなUIを素早く構築しつつWeb APIと連携するというのは、Titaniumが活用できるパターンだと思います。

というわけで、今回はREST APIとのData Bindingを試してみました。

REST API Sync Adapter

標準で用意されているSync Adapterは、SQLite用のSql AdapterとTitanium.App.Propertiesを使うProperties Adapterの2種類しかありませんが、Sync Adapterは自作することもできます。

Custom Sync Adapters - Alloy Sync Adapters and Migrations

REST API用のSync Adapterを作ってくれている人がいますので、これを採用することにします。

viezel/napp.alloy.adapter.restapi

今回作るもの

アプリ自体は簡単にするため、 その2 で作ったものと同じ仕様とします。

それとは別に、連携先のAPIが必要です。

適当なWeb APIを使っても良かったのですが、CRUDを一通り触りたかったのと、サーバー側に渡ってくるリクエストの内容も把握したかったので、今回はスタブAPIサーバーを立てることにしました。

スタブAPIサーバー

スタブAPIサーバーについては、シンプルで使いやすそうだったMockyを使ってみることにしました。

okv/mocky: http mocking server with simple config written on nodejs

テーブル

スタブなので実際のデータベースは用意しませんが、 その2 と同じ仕様ということで、以下のようなテーブル構造を扱うAPIを用意します。

内容 カラム
レコードの識別子 id INTEGER
メモの内容 contents TEXT
メモの優先度 priority TEXT

API

上記のテーブルをもとに、CRUDをひととおり呼び出せるよう、以下のようなAPIにすることにしました。

操作 Method URL
メモを読み込む GET http://127.0.0.1:4321/memos
メモを追加 POST http://127.0.0.1:4321/memos
メモの内容を更新 PUT http://127.0.0.1:4321/memos/{ID}
メモを削除 DELETE http://127.0.0.1:4321/memos/{ID}

スタブAPIPサーバー自体の実装は省略しますが、ダミーデータをメモリ上に保持しておいて、APIの呼び出しに合わせてダミーデータを操作しています。

例えばメモの読み込みAPIを叩くと、こんな感じにJSONのレスポンスを得られる感じです。

f:id:umi-uyura:20160227114353p:plain

データベースを使っていないので、サーバーを再起動するとデータは初期状態に戻ります。

ソースは umi-uyura/AlloyModelStudyRestStub にありますので、こちらをご覧ください。

Qiitaにあった mocky を使ってちょっと賢いスタブAPIサーバを作る - QiitaNode.js の mocky を使用し スタブREST API を作成してみる、その1 - Qiita あたりを読めば簡単に作れました。

アプリ

アプリ側のソースは umi-uyura/AlloyModelStudyRestApi です。

プロジェクト作成

毎度おなじみCLIでプロジェクト作成。

$ titanium create --type app --id com.example.titanium.alloymodelstudyrestapi --name AlloyModelStudyRestApi --platforms iphone,android --url http://www.example.com --workspace-dir .
$ alloy new AlloyModelStudyRestApi

これをベースに、今回もUIはJade & Stylusに、FokkeZB/UTiL/xp.ui を使っています。

REST API Sync Adapter (napp.alloy.adapter.restapi) の準備

GitHubリポジトリから restapi.js をダウンロードして、プロジェクト内の app/assets/alloy/sync/ へ配置しておきます。

モデル作成

先の想定テーブル構造から、ベースとなるモデルを生成します。このとき、 restapi タイプというのは Alloy コマンドがサポートしていないため、いったん sql として生成しました。

$ alloy generate model memo sql 'id:integer' contents:text priority:text

生成されたモデルファイルをもとに、REST API用に編集します。

exports.definition = {
  config: {
    columns: {
      "id": "integer",
      "contents": "text",
      "priority": "text"
    },
    adapter: {
      type: "restapi",
      collection_name: "memo",
      "idAttribute": "id"
    },
    "URL": "http://127.0.0.1:4321/memos",
    "debug": 1
  },
  extendModel: function(Model) {
    _.extend(Model.prototype, {
    });
    return Model;
  },
  extendCollection: function(Collection) {
    _.extend(Collection.prototype, {
      comparator: function(data) {
        return (0 - data.get('priority').length);
      }
    });

    return Collection;
  }
};

編集した箇所としては、以下のとおりです。

  • config.adapter.typerestapi に変更
  • URL を追加し、スタブAPIのURLを設定
  • debug を追加、これでAPIを使う際のリクエスト/レスポンスがデバッグ出力されてわかりやすい

他に headersparentNode をといった設定もありますが、今回は未使用。

以下はデバッグ出力の例。

f:id:umi-uyura:20160227114411p:plain

Data Binding

ビューの部分は 前回 と同じもので、コントローラもほぼ一緒で動きました。

一箇所修正したのは、メモを追加したあとに呼び出していた memos.fetch() の部分で、通信が発生してタイミングが変わるせいか、これを呼び出してしまうと追加したはずのメモが消えてしまう現象が発生しました。

その上の行の memos.add(m) までで、ListViewには新しいメモが追加されていたので、 memos.fetch() は削除することにしました。

app/controllers/index.js

...

function addText() {
  var txt = $.memoText.getValue();
  var params = {
    contents: txt,
    priority: ''
  };

  var m = Alloy.createModel('memo', params);

  m.save();
  memos.add(m);
  //memos.fetch();      <== 削除した

  $.memoText.setValue('');
}

...

エラーハンドリングについて

今回やっていないこととして、通信のエラーハンドリングがあります。

データ構造を同じにしたこともありますが、SQL用からREST API用にAdapterを入れ替えて、ほぼそのまま動かせてしまいました。

しかし、ローカルにあるデータベースなどと比べて、インターネットを介した通信は不安定だったり、APIサーバー側で何らかの問題が発生することなども考えられます。

そのため、napp.alloy.adapter.restapiには、以下のような感じで通信成功時とエラー時で処理をハンドリングできるようになっています。

var collection = Alloy.createCollection("MyCollection"); //or model
//the fetch method is an async call to the remote REST API.
collection.fetch({
    success : function(){
        _.each(collection.models, function(element, index, list){
            // We are looping through the returned models from the remote REST API
            // Implement your custom logic here
        });
    },
    error : function(){
        Ti.API.error("hmm - this is not good!");
    }
});

napp.alloy.adapter.restapiを使ってきちんと実装する場合は、この点も考慮する必要があると思います。

参考

モックサーバー

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Alloy Modelの学習・その3(Properties Adapter)

引き続き、Alloy Modelを深ぼってます。

umi-uyura.hatenablog.com

標準で用意されているSync Adapterには、SQLite用のSQL Adapterの他に、Titanium.App.Propertiesを使うProperties Adapterがあります。

※以前はもう一つ、Mobile Web用にlocalStorage adapterというのがあったようですが、Alloy 1.5.0で非推奨になってしまったようです。

Alloy Model、特にData Bindingを使う場面としては、SQLiteとの組み合わせで使うことの方が多い気がしますが、アプリのちょっとした設定を保持しておくのにTitanium.App.Propertiesを使うこともあるので、せっかくなので試しておくことにしました。

今回作るもの

設定画面で使う用途を想定して、1画面にいくつか入力項目があるもの、ということで、単純に名前・性別・国籍を登録しておく画面という、あまり実用性のないものになりました。

すべてテキスト入力だとつまらないので、性別だけOptionDialogを使って選択式にしています。

Android

f:id:umi-uyura:20160224234453p:plain f:id:umi-uyura:20160224234503p:plain

iOS

f:id:umi-uyura:20160224234513p:plain f:id:umi-uyura:20160224234524p:plain

ソースは umi-uyura/AlloyModelStudyProps にあがっているものです。

プロジェクト作成

例によってCLIでプロジェクト作成。

$ titanium create --type app --id com.example.titanium.alloymodelstudyprops --name AlloyModelStudyProps --platforms iphone,android --url http://www.example.com --workspace-dir .
$ alloy new AlloyModelStudyProps

これをベースに、今回もUIはJade & Stylusに、FokkeZB/UTiL/xp.ui を使っています。

モデル作成

上記の画面通り、以下のようなテーブルにします。

内容 カラム
ID id String
名前 name String
性別 gender String
国籍 nationality String

これをもとに alloy generate コマンドを使ってモデルを生成。

$ alloy generate model settings properties id:string name:string gender:integer nationality:string

今回想定している設定画面の場合は複数レコードが発生することはないため、レコードを識別する id は不要な気がしますが、Data Binding(というよりはBackbone?)の仕組み上、キーとなる属性が必要なようです。キーとなる属性がない場合、設定を更新しようとしたときに該当レコードが見つけられず、新しいレコードが作られてしまいます。

生成されたモデルを、最終的に以下のようにしました。

app/models/settings.js

exports.definition = {
  config: {
    columns: {
        "id": "string",
        "name": "string",
        "gender": "string",
        "nationality": "string"
    },
    defaults: {
      "id": "settingsId",
      "name": "",
      "gender": "",
      "nationality": ""
    },
    adapter: {
      type: "properties",
      collection_name: "settings",
      idAttribute: "id"
    }
  },
  extendModel: function(Model) {
    _.extend(Model.prototype, {
      // extended functions and properties go here
    });

    return Model;
  },
  extendCollection: function(Collection) {
    _.extend(Collection.prototype, {
      // extended functions and properties go here

      // For Backbone v1.1.2, uncomment the following to override the
      // fetch method to account for a breaking change in Backbone.
      /*
      fetch: function(options) {
        options = options ? _.clone(options) : {};
        options.reset = true;
        return Backbone.Collection.prototype.fetch.call(this, options);
      }
      */
    });

    return Collection;
  }
};

加えたのは config.defaults で、特に id には何か初期値を入れておかないと(つまりあらかじめキーを登録しておく)、先ほど述べたレコードが見つからない状態となってしまうようです。

画面とData Binding

今回もスタイルはソースを参照してもらうとして、Viewの index.jade は以下のようにしました。

app/views/index.jade

Alloy
  Model(src="settings")/
  NavigationWindow(module="xp.ui")
    Window.container(title="Alloy Model Study: Properties", onOpen="doOpen")
      View.line
        Label.label Name:
        TextField#textName.textfield(value="{settings.name}")
      View.line
        Label.label Gender:
        TextField#textGender.textfield(value="{settings.gender}", onClick="doGenderClick")
      View.line
        Label.label Nationality:
        TextField#textNationality.textfield(value="{settings.nationality}")
      View.controll
        Button#saveButton(onClick="doSaveClick") Save

前回のData Bindingと少し異なるのは、設定情報は1レコードでCollectionは不要なため、 <Alloy> タグ直下に記述するBindingする対象に関して、 <Model> タグで指定してみました。

各TextFieldにData Bindingする項目を設定し、これをもとにコントローラ側も実装。

app/controllers/index.js

var settings = Alloy.Models.settings;

function doOpen() {
  settings.fetch();
}

function doSaveClick(e) {
  var name = $.textName.getValue();
  var gender = $.textGender.getValue();
  var nationality = $.textNationality.getValue();

  settings.save({
    name: name,
    gender: gender,
    nationality: nationality
  });
}

...

Viewの open イベントで Model.fetch() してデータを読み込み、画面に配置したSaveボタンのクリック時に Model.save() してデータを保存しています。

Modelオブジェクト単独を扱っているため、Collectionから該当レコードを検索する処理がないこともあり、簡潔に書くことができました。

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Alloy Modelの学習・その2(マイグレーション)

前回の学習内容をもとに、せっかくなのでマイグレーション機能も試してみることにしました。

umi-uyura.hatenablog.com

今回のサンプルは前回と同じプロジェクトの step2 というブランチにあります。

umi-uyura/AlloyModelStudy at step2

マイグレーションを試す場合は、まず master ブランチをビルドして動かしてから、 step2 ブランチをビルドして実行することで発動します。

マイグレーションする内容

マイグレーションとは、テーブルにカラムを追加したいといった場合に、データベースを作りなおすのではなく、データを保持したまま変更を加える方法。

というわけで、前回のプロジェクトで作成したテーブルを加工してみることにしました。

ただ、新しい項目を加えるとUIも変更しなくてはならなくなり少々面倒なので、既存データの扱いを変更することで試すことに。

前回時のデータベースの構造は以下のとおり。

内容 カラム 主キー
レコードの識別子 id INTEGER AUTOINCREMENT
メモの内容 contents TEXT - -

変更内容としては、お気に入りということで文字列の先頭につけていた * を分離する、というものにしました。また * の登録 できる数には制限をかけていなかったこともあり、その数が多いほど優先度が高い扱いにすることにしました。

そこで、マイグレーション後のテーブル構造は以下のように変更することにしました。

内容 カラム 主キー
レコードの識別子 id INTEGER AUTOINCREMENT
メモの内容 contents TEXT - -
メモの優先度 priority TEXT - -

よってマイグレーションで実施する処理としては、 contents から先頭の * 部分を切り出して、 priority へセットする、というものになります。

マイグレーション処理の実装

マイグレーションファイルの生成

マイグレーションの内容はJavaScriptで実装できます。

そのファイル名は、モデル名をベースに、マイグレーションの世代管理のための日時情報を YYYYMMDDHHmmss_ として接頭部に付与したファイルを用意します。

この雛形は、 alloy generate コマンドで生成することもできます。

$ alloy generate migration memo
[INFO] Generated migration named 201602180643382_memo

上記のコマンドを実行した例としては、 app/migrations フォルダ内に 201602180643382_memo.js が生成されます。

マイグレーションファイルの構造

生成されたマイグレーションファイルは、以下の2つの関数によって構成されています。

migration.up = function(migrator) {
};

migration.down = function(migrator) {
};

どちらも migrator というオブジェクトを引数に取っていますが、この中にデータベースオブジェクトの参照や、テーブル名などの情報、そしてデータベース操作用の関数を持っています。この migrator オブジェクトを使って、マイグレーション処理を実装します。

migrator が持っている機能は以下のとおり。

キー 説明
db Ti.Databaseインスタンス ※このインスタンスを閉じたり、別のDBを開いたりするのはNG!
dbname データベース名
table テーブル名。 config.adapter.collection_name と同じもの。
idAttribute データベースのプライマリーキーとなる属性名
createTable データベースにテーブルを作成する。Alloy Modelの columns と同じ構造でテーブル定義を渡す。
dropTable データベースからテーブルを削除する
insertRow テーブルにレコードを追加する
deleteRow テーブルからレコードを削除する

migration.up() は前回のバージョンからデータベースをアップグレードする処理を、 migration.down() は変更点をロールバックして前回のバージョンに戻す処理を実装します。

ちなみに、そもそもですが、マイグレーション機能はsql sync adapterでのみ利用することができる機能のようです。

例)新しいテーブルを追加するマイグレーション

今回は既存のテーブルに変更を加えるパターンですが、新しいテーブルを追加する場合の例です。

この場合は、 migrator.createTable()migrator.dropTable() を使ってマイグレーションおよびロールバック処理を書くようです。

Appceleratorドキュメントの例によると、以下のような感じです。

var preload_data = [
    {title: 'To Kill a Mockingbird', author:'Harper Lee'},
    {title: 'The Catcher in the Rye', author:'J. D. Salinger'},
    {title: 'Of Mice and Men', author:'John Steinbeck'},
    {title: 'Lord of the Flies', author:'William Golding'},
    {title: 'The Great Gatsby', author:'F. Scott Fitzgerald'},
    {title: 'Animal Farm', author:'George Orwell'}
];
 
migration.up = function(migrator)
{
    migrator.createTable({
        "columns":
        {
            "book": "TEXT",
            "author": "TEXT"
        }
    });
    for (var i = 0; i < preload_data.length; i++) { 
        migrator.insertRow(preload_data[i]);
    }
};
 
migration.down = function(migrator)
{
    migrator.dropTable();
};

migrator.createTable() に、モデルの columns と同じようにテーブル定義情報を渡すことで新しいテーブルが作成されるので、必要に応じてさらに新しいデータを migrator.insertRow() で突っ込んでいます。

ロールバックする場合は、そのテーブル自体を migrator.dropTable() で削除してしまえば、もとに戻せるということのようです。

カラムを追加するマイグレーション

今回のようなカラムを追加するようなマイグレーションの場合は、直接SQLを発行して処理します。

今回の場合、

  1. 優先度カラムを追加
  2. 既存データから優先度を判定してデータを更新

という処理が必要になるので、以下のような感じに実装してみました。

app/migrations/201602180643382_memo.js

migration.up = function(migrator) {
  var db = migrator.db;
  var table = migrator.table;

  migrator.createTable({
    columns: {
      'id': 'integer primary key autoincrement',
      'contents': 'text'
    }
  });

  db.execute('ALTER TABLE ' + table + ' ADD COLUMN priority STRING DEFAULT ""');

  var rows = db.execute('SELECT * FROM ' + table + ';');
  while (rows.isValidRow()) {
    var contents = rows.fieldByName('contents');

    var priorityPos = contents.lastIndexOf('*');
    if (0 > priorityPos) {
      rows.next();
      continue;
    }

    var priorityString = contents.substring(0, priorityPos + 1);
    var contentsBody = contents.substring(priorityPos + 1);

    var updateQuery = 'UPDATE ' + table +
          ' SET priority = "' + priorityString + '", contents = "' + contentsBody +
          '" WHERE id  = ' + rows.fieldByName('id') + ';';

    db.execute(updateQuery);

    rows.next();
  }
};

SQLiteDROP COLUMN が使えないので、直接テーブルからカラムを削除することができません。そこで、カラムが減った新しいテーブルを作成して、そこにデータを入れなおす必要があるようです。

最初に migrator.createTable() をしていますが、これはこのマイグレーションを実施するバージョンのアプリを新規でインストールする場合、まだテーブルが作成されていない可能性があるからです。 migrator.createTable() は内部で IF NOT EXISTS を使っているので、すでにテーブルがある状態で呼び出しても問題ありません。

その後、 ALTER TABLEpriority カラムを追加してから、 contents の先頭にある * を取り出して priority へ格納するという処理を、すべての行に対して実施しています。

今回は関係ありませんでしたが、マイグレーション時の注意事項として、もしモデルに idAttribute を指定していない場合、 alloy_id というカラムを自動的に追加しているらしので、これを含めるのを忘れないようにする必要があるようです。

contents から * を取り除いたので、画面側にも priority を表示するため、ListItemの表示内容にも priority を追加しました。

app/views/index.jade

ListItem(title="{priority} {contents}", itemId="{id}")/

ロールバックする( priority カラムがない状態に戻す)

さて、マイグレーションが想定通り動くようになったので、次に migration.down()ロールバック処理を実装してみることに。

これは、データベースをマイグレーション前に戻す処理になります。

そこで、テンポラリのテーブルを作って、既存のデータを避難させ、改めてマイグレーション前の構造のテーブルを作成し、そこにデータを加工しつつ投入する、という感じで実装してみました。

migration.down = function(migrator) {
  var db = migrator.db;
  var table = migrator.table;
  var backup_table = table + '_backup';

  db.execute('CREATE TEMPORARY TABLE ' + backup_table + '(id,contents,priority);');
  db.execute('INSERT INTO ' + backup_table + ' SELECT id,contents,priority FROM ' + table + ';');

  migrator.dropTable();
  migrator.createTable({
    columns: {
      'id': 'integer primary key autoincrement',
      'contents': 'text'
    }
  });

  var rows = db.execute('SELECT * FROM ' + backup_table + ';');
  while (rows.isValidRow()) {
    var id = rows.fieldByName('id');
    var contents = rows.fieldByName('contents');
    var priority = rows.fieldByName('priority');

    var updateQuery = 'INSERT INTO ' + table + ' (id,contents) VALUES (' + id + ',"' + priority + contents + '");';
    db.execute(updateQuery);

    rows.next();
  }

  db.execute('DROP TABLE ' + backup_table + ';');
};

で、ここまで実装して、ふと気付いたのですが、

・・・ロールバックってどうやって確認するんだろ?

声(ツイート)に出てた。

Appceleratorのドキュメントにもそのあたり発見できず、いろいろ探していたところ、最近Q&AフォーラムがAppcelerator公式サイトから移行したStack Overflowの方で、それらしい回答を発見しました。

appcelerator - Lifecycle of Alloy Migrations - Stack Overflow

それによると、 migration.down() は何らかの方法で古いバージョンのアプリへアップグレード?したときに発動するもよう。

ただし、App Storeで公開しているものでは発動しないみたいで、これはアプリをダウングレードする手段が提供されていないから、ということかもしれません。

よって使える場面としては、テスト中やAd Hoc/Enterprise配布のときということになりそうです。

確かに本番リリースしたアプリでロールバックさせるような状況って思いつかない(必要なら再度アプリをアップデートする)ので、あまり使う機会ないのかも。

ついでに表示順序も改良

どうせ優先度をつけるなら、ListViewへの表示順序も優先度が高いとしているものから並ばせたい、と思ったので、その部分も合わせて変更することにしました。

Backbone.Collectionは、基本的にモデルを追加した順序が基準になるようです。

そこで、 priority の数が多い順が基準となるように、 Collection.comparator を実装しました。

app/models/memo.js

exports.definition = {
  ...
  extendCollection: function(Collection) {
      comparator: function(data) {
        return (0 - data.get('priority').length);
      }
    });

    return Collection;
  }
};

これで、 model.fetch() してきたときに priority 順で表示されるようになりました。

もう1点、お気に入りの登録をして priority が変化した時には、 Collection.sort() を呼び出して再度並び替えを実施することで、更新後のお気に入り数に応じた順序で表示されるようになりました。

app/controllers/index.js

function clickItem(e) {
  var optionDialog = Ti.UI.createOptionDialog(opt);
  optionDialog.addEventListener('click', function(e) {
    switch (e.index) {
    case 0:
      var m = memos.where({id: itemId});
      if (m.length) {
        var priority = '*' + m[0].get('priority');
        m[0].set({priority: priority});
        m[0].save();
        memos.sort();       // <= 追加
      }
      break;
    }
  });
  optionDialog.show();
}

Data Bindingのおかげで、UI部分を操作するコードは書くことなく、Collectionの順序が画面に反映されました。

f:id:umi-uyura:20160222233635p:plain f:id:umi-uyura:20160222233650p:plain

見た目的には前回と変わっていないのですが、お気に入り( * )の数が多いものが、より上位に表示されるようになりました。また、お気に入りの数を増やした場合、より上位に移動します。

感想

今のところマイグレーションを使うような予定はないのですが、そういうのもあるのか、ということで。

参考

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Alloy Modelの学習

実は、これまではAlloyといってもView/Controllerしか使っておらず、データベースを使う場合も Titanium.Database を使ってしまっていたので、ちゃんとModelを活用したことがありませんでした。

というわけで、今更ながらにAlloy Modelを触り始めてみました。

今回作るもの

Alloyの基本的な動きを把握するため、SQLiteデータベースを使う、簡単なメモツールのようなものを作りました。

Android

f:id:umi-uyura:20160217235441p:plain f:id:umi-uyura:20160217235451p:plain

iOS

f:id:umi-uyura:20160217235502p:plain f:id:umi-uyura:20160217235511p:plain

ソースは umi-uyura/AlloyModelStudy です。

仕様的なもの

  • シンプルに入力欄とリストのみ
  • 入力欄に入力したテキストを、順番にリストに登録していくだけ
  • リストの項目をタップすると、操作メニューを表示
  • 操作メニューからは、お気に入り登録(するとメモの頭に * をつける)もしくはメモの削除ができる
  • アプリを一旦終了して再度起動しても、登録したデータが表示されている

という感じで、Alloy Modelを使ったCRUDとListViewへのData Bindingをざっと触れる想定です。

実装

プロジェクトの作成

CLIベースで開発しているため、ターミナルからiPhoneAndroidを対象にしたAlloyプロジェクトを作成。

$ titanium create --type app --id com.example.titanium.alloymodelstudy --name AlloyModelStudy --platforms iphone,android --url http://www.example.com --workspace-dir .
$ alloy new AlloyModelStudy

Modelを作成

メモを保存するためのModelを作成します。

テーブルの構造は超シンプルに以下のような感じとしました。

内容 カラム 主キー
レコードの識別子 id INTEGER AUTOINCREMENT
メモの内容 contents TEXT - -

これをもとに alloy generate model でModelのコードを生成します。モデル名につづいて sql を指定することで、SQLite向けの構成になります。

$ alloy generate model memo sql 'id:integer primary key autoincrement' contents:text

プロジェクト内の app/models/memo.js に生成されました。

生成されたコードに1点手を加える必要があり、テーブル内のレコードを識別するためのカラムを設定するために、 config.adapter.idAttribute を追加して id を指定しました。

できあがったモデルのコードは以下のような感じ。

app/models/memo.js

exports.definition = {
    config: {
        columns: {
            "id": "integer primary key autoincrement",
            "contents": "text"
        },
        adapter: {
            type: "sql",
            collection_name: "memo",
            idAttribute: "id"
        }
    },
    extendModel: function(Model) {
        _.extend(Model.prototype, {
            // extended functions and properties go here
        });

        return Model;
    },
    extendCollection: function(Collection) {
        _.extend(Collection.prototype, {
            // extended functions and properties go here

            // For Backbone v1.1.2, uncomment the following to override the
            // fetch method to account for a breaking change in Backbone.
            /*
           fetch: function(options) {
               options = options ? _.clone(options) : {};
               options.reset = true;
               return Backbone.Collection.prototype.fetch.call(this, options);
           }
           */
        });

        return Collection;
    }
};

UIを実装

私のふだんのAlloy開発スタイルどおり、UIはJade&Stylusで作っています。もしプロジェクトを動かす方がいたら、JadeとStylusをグローバルにインストールしておいてください。

umi-uyura.hatenablog.com

また、iPhoneAndroid両方でナビゲーションバー/アクションバーを表示するために、Fokke Zandbergen氏が作っている xp.ui を使っています。これは、本来iOSでしか使えないNavigationWindowのダミーを用意することで、Androidとコードを共通化できる便利なライブラリです。

そんな感じで、スタイルの方はソースを見ていただくとして、Viewとなる index.jade は以下のようにしました。

app/view/index.jade

Alloy
  Collection(src="memo")/
  NavigationWindow(module="xp.ui")
    Window.container(title="Alloy Model Study", onOpen="doOpen")
      View.controller
        TextField#memoText
        Button#addButton(title="Add", onClick="addText")
      ListView#memoList(onItemclick="clickItem")
        ListSection(dataCollection="memo")
          ListItem(title="{contents}", itemId="{id}")/

まずは、このViewで利用するCollectionを Alloy タグの直下に指定。

Alloy
  Collection(src="memo")/

さらに、後述するData Bindingのために、ListSectionに利用するCollectionの指定を、またListItemに表示する memo Modelのフィールドを指定しています。

ListSection(dataCollection="memo")
  ListItem(title="{contents}", itemId="{id}")/

データベース操作の実装

app/controllers/index.js に、Data Bindingを利用したデータベース操作を実装しました。

レコードの読み込み、ListViewへの表示

アプリ起動時に、データベースからレコードを読み込みます。

これは Collection.fetch() で読み込むことができるので、Windowの open イベント内で呼び出しておきます。これによって、画面が開かれたタイミングで、データベースにレコードがあれば、それが読み込まれることになります。

var memos = Alloy.Collections.memo;

function doOpen() {
  memos.fetch();
}

さらに、View内でListSectionに memo Collectionを紐付けている( dataCollection)ので、読み込まれたレコード分、そのListSectionのListItemとして生成されます。

ListItemには title として memo モデルの contents フィールドを指定しているので、そこに登録されているレコードがListItemに表示されることになります。

レコードの追加

TextFieldに文字を入力して Add ボタンを押すと、新しいレコードを登録します。

その処理は以下のように実装。

function addText() {
  var txt = $.memoText.getValue();

  var m = Alloy.createModel('memo', {
    contents: txt
  });

  memos.add(m);
  m.save();
  memos.fetch();

  $.memoText.setValue('');
}

新しいレコードを作成するには、 Alloy.createModel で新しいModelオブジェクトを生成。

そのModelオブジェクトをCollectionにを追加して Model.save() を呼び出すことで、データベースにレコードが保存されます。

その後、再び Collection.fetch() を呼び出すと、追加したレコードに対応するListItemがリストに追加されます。

レコードを編集・更新

編集画面を別途作ろうかと思ったのですが、少々面倒なので中止。かわりに、お気に入りという扱いとして、登録した文字列の先頭に * を付与するというだけの仕様にしています。

ListViewの click イベントでOptionDialogを表示して、お気に入り登録か削除かをするようにしました。

どのListItemに対する操作なのかを判別するための情報は、イベントの引数から取得できます。

function clickItem(e) {
  var secIdx = e.sectionIndex;
  var itemIdx = e.itemIndex;
  var itemId = e.itemId;
  var text = $.memoList.sections[e.sectionIndex].items[e.itemIndex].properties.title;
   
  if (OS_ANDROID) {
    itemId = parseInt(itemId);
  }
  ...

ここでひとつハマったのが、レコードのキーとなる itemId

memo modelの id を紐付けているのですが、元の id は数値型( integer )にしていたのに対して、 itemIdAPIドキュメントによると文字列型( string )でした。

この点、iOSではうまいこと型変換してくれているらしく問題はなかったのですが、Androidに関してはドキュメントどおり文字列型で取れるため、このあとの Collection.where で型不一致のせいかデータが取れないという問題がありました。

もしかしたら、数値型IDは itemId として使わないほうが良いのかもしれませんが、今回はAndroidの場合 parseInt することで対処しています。

その後、既存レコードを編集します。

var m = memos.where({id: itemId});
if (m.length) {
  var contents = '*' + m[0].get('contents');
  m[0].set({contents: contents});
  m[0].save();
}

Collection.where() は配列を返すので、 Array.length で存在を確認してから処理を実施。

変更内容を Model.set() して、 Model.save() を呼ぶことで、Data Bindingのおかげで、すぐに変更がリストにも反映されます。

ちなみに、Alloy Modelの仕組みのベースになっているのはBackbone.jsですが、Alloyに含まれているBackbone.jsは 0.9.2 と古いバージョンです。このBackbone.jsのバージョンを 1.1.2 にすると単独のModelを返す Collection.findWhere() が使えるようになるので、上記のようなわかりにくさが解消できそうです。

Backbone.jsのバージョンを変える方法は Alloy Backbone Migration を参照。

レコードを削除

レコードの削除処理は以下のように実装。

var dialogs = require('alloy/dialogs');
...
dialogs.confirm({
  title: 'Do you want to delete ?',
  message: '"' + text + '"',
  callback: function() {
    var m = memos.where({id: itemId});
    if (m.length) {
      m[0].destroy();
    }
  }
});

削除を確認するダイアログを出したのち、削除を実行します。

レコードの編集と同様の方法で既存レコードを取得し、 Model.destroy() で、呼出し後リストから該当項目が消えました。

まとめ

簡単なサンプルですが、最低限のAlloy Modelの使い方は理解できたかな、と思います。

今まで使う機会がなかったのですが、やはりData Bindingは便利ですね。

ここ を見ると、ListView(正確にはListSection)以外にもData BindingできるViewはありますが、やはり一番使いどころがあるのはListViewだと思いますし、さらにListItemのテンプレートの仕組みと組み合わせると、いろんな表現ができそう。

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Titanium.UI.ListViewのイベントまわりを調べてみた

ふと試してみたところ、Titanium.UI.ListViewのドキュメントに掲載されていない swipe イベントが取れたので、もしかして他の使えるのかも?と思って調べてみました。

ドキュメントに掲載されているクリックイベント以外だと、スワイプと長押しあたりが使えると広がるな―などと思って始めたのですが、、、まあ結論を書くと、swipe イベント自体は発行されるものの、個々のListItemのアクションとしては使えないものだったのでした。

ということを調べたメモです。

確認した環境

確認したこと

  • ListSection/ListItemでイベントが取れるのか?
  • どのイベントを受け取ることができるのか?

ListSection/ListItemでイベントが取れるのか?

そもそもListSectionやListItemでイベントは取れないのか?という点を念のため再確認。

それぞれに addEventListener をしたところ not a function になってしまったので、ここはやはりListViewでハンドリングするしかなさそう。

もう半分答えが出た感じ。

どのイベントを受け取ることができるのか?

公式ドキュメント上で特定のListItemを識別するようなイベント itemclick のみですが、他のViewなどで持っているものでListItemへの操作をハンドリングできそうなイベントを見繕って調べてみました。

Alloyベースのプロジェクトを作って、思いついたイベントハンドラを片っ端から追加して、ポチポチさわってみた結果は以下のような感じ。

イベント iOS Android 補足
itemclick -
swipe イベントは発生するが、 sectionIndexitemIndex は取れない ※1
click - - 発生しない
singleclick - - 発生しない
dblclick - Androidは、ListItemがない部分でのみ発生する
longclick - - 発生しない
longpress iOSは発生するが、 itemclick も発生する
Androidは、ListItemがない部分でのみ発生する
singletap iOSは発生するが、 itemclick も発生する
Androidは、ListItemがない部分でのみ発生する
doubletap - - 発生しない
twofingertap iOSは発生するが、 itemclick も発生する
Androidは、ListItemがない部分でのみ発生する

というわけで、やはりListViewで特定のListItemへの操作に対応するためのイベントは itemclick だけ。要するに、ListViewのイベントということですね。

ちなみに、Androidで受け取れる itemclick 以外のイベントで sectionIndexitemIndex が取れることがあったのですが、その操作以前にクリックしているListItemの情報が取れてしまっているだけで、その操作に対応した情報ではありませんでした(紛らわしい!)

まとめ

標準のListViewのみだと、凝った操作はできない印象。

他の方法としては、ItemTemplateにいろいろViewを設置して、そこでイベントハンドリングすれば取れそうな気もしますが、もともとやろうとしていることから外れているので、いったんここまで。

調べるのに作ったプロジェクトは これ

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

コマンドラインのチートシート管理ツール「Cheat」

先日、Twitterを眺めていて、tldrというコマンドを知りました。

man は長すぎるので「tldr」

これは便利、とさっそく導入したのですが、どうせなら自分で使うパターンを登録できるものがないものかと思って探してみたところ、Cheatというツールを発見しました。というか、 tldrのGitHubリポジトリのREADME で紹介されていました。

chrisallenlane/cheat

基本的にはtldrと同様、指定したコマンドのよくある使い方のようなものを表示してくれるものですが、それ以外にも表示内容を編集したり、新しいコマンドの使い方を追加することができる、というものです。

インストール

Homebrewで入れることができます。

$ brew install cheat

コマンド自体はPythonで作られているようなので、 pip でもインストールできるようです。

使い方

チートシートの表示 cheat <command>

引数として、使い方を知りたいコマンドを渡すことで、その主だった使い方を表示します。

例えば curl の使い方例を観たい場合は、以下のように実行します。

$ cheat curl
# Download a single file
curl http://path.to.the/file

# Download a file and specify a new filename
curl http://example.com/file.zip -o new_file.zip

...

チートシートの編集 cheat -e <command>

エディタが起動して、表示内容を編集することができるようになります。

環境変数 EDITOR を参照しているようです。

自分はよく curl でリダイレクトも辿ってダウンロードをするオプションを忘れるので、 curlチートシートを編集して、以下の例を追記しておきました。

# Follow the redirect
curl -L http://example.com/file

パーミッションに注意

私の環境では cheat -e したときに、読み取り専用状態で開かれました。

Homebrewで導入すると、オリジナルのチートシートファイルは /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/ といったパスにコマンド単位で格納されているようなのですが、そのオリジナルを保護するためか、各ファイルは読み取り専用になっていましたので、そのためかと思います。

この場合は、編集後に別名保存して、必要なら chmod で書き込み権限を付与すればOKです。

なお、編集した個人用のチートシートは、デフォルトでは ~/.cheat というパスに保存されるようです。これを変更したい場合は、 DEFAULT_CHEAT_DIR という環境変数を設定すれば良いようです。

チートシートの検索 cheat -s <keyword>

指定したキーワードを含むチートシート内を検索することができます。

例えば キーワードとして awk を検索すると、以下のように出力されます。

$ cheat -s awk
awk:
  printf '1\n2\n3\n' | awk '{ sum += $1} END {print sum}'
  printf '1:2:3' | awk -F ":" '{print $1+$2+$3}'
  seq 9 | sed 'H;g' | awk -v RS='' '{for(i=1;i<=NF;i++)printf("%dx%d=%d%s", i, NR, i*NR, i==NR?"\n":"\t")}'
  printf '1 2 3' | awk 'BEGIN {OFS=":"}; {print $1,$2,$3}'

find:
  find . -type f -size +20000k -exec ls -lh {} \; | awk '{ print $9 ": " $5 }'

history:
  history | awk '{CMD[$2]++;count++;}END { for (a in CMD)print CMD[a] " " CMD[a]/count*100 "% " a;}' | grep -v "./" | column -c3 -s " " -t | sort -nr | nl | head -n10

netstat:
  netstat -pln | grep <port> | awk '{print $NF}'

openssl:
  awk '/-----BEGIN/,/END CERTIFICATE-----/' | \

awk の使用例のほか、内部的に awk を使っているケースもヒットするので、コマンドの勉強にもなるかもしれません。

チートシートの一覧 cheet -l

こんな感じで、ズラズラっと表示されます。

7z               /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/7z
ab               /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/ab
apk              /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/apk
apparmor         /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/apparmor
apt-cache        /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/apt-cache
apt-get          /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/apt-get
aptitude         /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/aptitude
asciiart         /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/asciiart
asterisk         /usr/local/Cellar/cheat/2.1.18/lib/python2.7/site-packages/cheat/cheatsheets/asterisk
...

上記だとチートシート自体のパスも出力されていて少し見にくいので、コマンドだけ抽出すると、2/9時点(Cheat 2.1.18)で、以下のとおりでした。

7z, ab, apk, apparmor, apt-cache, apt-get, aptitude, asciiart, asterisk, at, awk, bash, bower, chmod, chown, convert, crontab, csplit, cups, curl, cut, date, dd, df, dhclient, diff, distcc, dnf, docker, dpkg, du, emacs, ffmpeg, find, gcc, gdb, git, gpg, grep, gs, gzip, hardware-info, head, history, hub, ifconfig, indent, ip, iptables, irssi, iwconfig, journalctl, jq, jrnl, less, lib, ln, ls, lsof, lvm, man, markdown, mkdir, more, mount, mysql, mysqldump, nc, ncat, netstat, nmap, notify-send, nova, ntp, numfmt, od, openssl, p4, pacman, paste, pdftk, php, ping, ping6, pip, ps, python, readline, rm, route, rpm, rss2email, rsync, sam2p, scp, screen, sed, shred, smbclient, snmpwalk, sockstat, sort, split, sqlmap, ss, ssh, ssh-copy-id, ssh-keygen, stdout, strace, systemctl, systemd, tail, tar, tcpdump, tee, tmux, top, tr, truncate, udisksctl, uname, uniq, unzip, vagrant, vim, weechat, wget, xargs, yaourt, youtube-dl, yum, zip, zoneadm

面倒くさいので両方使いたい

ところで、同じタイミングのtldrの対応コマンドは以下のとおり。

※tldrは様々な言語・プラットフォームのものが用意されていますが、自分的に導入しやすいNode.js版を使っています。

7za, ab, ack, adb, ag, alias, apropos, ar, aria2c, arp, atom, autojump, autossh, awk, axel, bash, bashmarks, bc, bmaptool, bundle, cal, calibre-server, calibredb, cat, cd, chmod, chown, chsh, cksum, clang, comm, convert, convmv, cordova, cowsay, cp, csvclean, csvcut, csvformat, csvgrep, csvlook, csvpy, csvsort, csvstat, curl, cut, date, deluser, df, dhcpwn, diff, dig, docker, dokku, drush, ebook-convert, echo, electrum, emacs, enca, env, espeak, exiftool, fdupes, ffmpeg, file, find, for, fortune, fswebcam, fzf, gcc, gem, gifsicle, git-add, git-blame, git-branch, git-checkout, git-clone, git-commit, git-config, git-diff, git-fetch, git-init, git-log, git-merge, git-mv, git-pull, git-push, git-rebase, git-remote, git-rm, git-stash, git-status, git-svn, git-tag, git, glances, gpg, gradle, grep, gzip, handbrakecli, haxelib, history, host, iconv, if, ifconfig, in2csv, ionice, ioping, ipcs, jar, java, javac, kill, last, latexmk, less, license, ln, lp, lpstat, ls, lsof, lwp-request, mailx, make, man, mitmproxy, mkdir, mocha, montage, more, mount, mp4box, mtr, mv, mysql, mysqldump, nano, nc, nginx, nice, nix-env, nmap, node, nohup, npm, nvm, openssl, optipng, pandoc, parallel, pass, passwd, paste, patch, pdflatex, pg_dump, pg_restore, pgrep, php, phpize, phpunit, pigz, ping, pip, pkill, play, pngcrush, printf, ps, psql, pushd, pv, pwd, python, read, redis-cli, redshift, rename, renice, rm, rmdir, route, rsync, sails, salt-call, salt-key, salt-run, salt, sass, scp, screen, sed, seq, sftp, shred, skicka, sl, socat, sort, sox, split, srm, ssh-keygen, ssh, sshfs, strings, su, sudo, sum, svn, tabula, tac, tail, tar, tcpdump, tee, telnet, test, time, tldr, tldrl, tmux, touch, tr, traceroute, transcode, tree, ufraw-batch, umount, uname, uniq, unrar, unzip, uptime, vagrant, vim, vimtutor, virtualenv, w, watch, wc, wget, which, while, who, whoami, x_x, xargs, xz, yes, youtube-dl, zbarimg, zcat, zdb, zfs, zip, zless, zpool, airport, archey, base64, brew, caffeinate, dd, diskutil, ditto, drutil, du, head, hostname, locate, md5, mdfind, netstat, networksetup, nm, open, pbcopy, pbpaste, qlmanage, route, say, shutdown, sw_vers, sysctl, system_profiler, systemsetup, top, w, wacaw, xctool, xed, xsltproc

tldrの方がサポートしているコマンドは多く、またCheatと違ってチートシートの内容のみアップデートできる( tldr -u )ので、今後もまだまだ増えていきそう。

よって普段使い的にはtldrの方がヒットする率が高そうですが、Cheatは自分用のチートシートを用意できるのが便利。

ということで、両方呼び出すシェルスクリプトを作って使うことにしました。

以下のgistを実行権限付きでPATHが通った場所に保存して、 how <command> で使えます。

実行すると、以下のような感じで cheattldr 両方の結果を出力します。

$ how curl
======================================= cheat
# Download a single file
curl http://path.to.the/file

# Download a file and specify a new filename
curl http://example.com/file.zip -o new_file.zip

# Follow the redirect
curl -L http://example.com/file

...

======================================== tldr

  curl
  Transfers data from or to a server.
  Supports most protocols including HTTP, FTP, POP.

  - Download a URL to a file:
    curl "URL" -o filename

  - Send form-encoded data:
    curl --data name=bob http://localhost/form

  - Send JSON data:
    curl -X POST -H "Content-Type: application/json" -d '{"name":"bob"}' http://localhost/login

...

参考

すべてのUNIXで20年動くプログラムはどう書くべきか デプロイ・保守に苦しむエンジニア達へ贈る[シェルスクリプトレシピ集]

すべてのUNIXで20年動くプログラムはどう書くべきか デプロイ・保守に苦しむエンジニア達へ贈る[シェルスクリプトレシピ集]

ti-deploygate-hookをアップデートした(0.2.0)

ti-deploygate-hook は、Titanium CLIでビルド後、DeployGateへアップロードするためのプラグインです。

変更点

  • Titanium SDK 5.x対応
  • dgコマンド対応

Titanium SDK 5.x対応

Titanium SDK 5.xで、iOS版をビルドしたときのフォルダ構造が変わりました。

Titanium SDK 4.xまでは

build/iphone/build/Debug-iphoneos

というような階層だったものが、

build/iphone/build/Products/Debug-iphoneos

と、2つ目の build フォルダの下に Products というフォルダが加わるようになっていたので、それに合わせてビルド時のSDKバージョンによって、.ipaのパス指定を切り替えるようにしました。

なお、Android版は変わっていないようです。

dgコマンド対応

これまでのti-deploygate-hookは、内部でDeployGateが提供しているCLIインターフェイスdgate コマンドを使っていたのですが、新しく公開された dg (deploygate-cli) コマンドも使えるようにしました。

参考: 開発中のアプリを1コマンドで共有する方法 | DeployGate Blog

dgateの方はアップデートも停止され、今後の機能追加はdgコマンドで行うようなので、移行してしまうのが良さそうです。

dgコマンドを使う場合は、これまで使っていた --dgate のかわりに --dg を指定すればOKです。

$ titanium build -p ios --target device --dg

--dg のうしろにメッセージを指定することもできます。

$ titanium build -p ios --target device --dg "Upload by dg command !"

なお、 --dg を使うにあたっては、事前にdgコマンドのインストールが必要です。

dgコマンドについて詳しくは、 DeployGateのサイト をご覧ください。

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition