本気のiOS開発者は読むべき一冊「UIKit徹底解説 iOSユーザーインターフェイスの開発」

入門書や入門講座などを終わり、これから本気でiOSアプリを作るぞ! と思っているいる人は買うべき一冊です。またiOSアプリを一つ二つ作った人も「なるほど!」と思うUIKitの知識が詰まっています。

内容はiOS7用に書かれているので iOS8で導入された機能やSwift言語には対応していませんが、ここに書かれている内容は iOS8でもほとんどが使えます。また SwiftiOSアプリを書く場合もUIKitは同じですから、ほんの少しの脳内コード変換で使えます。

電子書籍も買えるのが嬉しいですね (Kindle以外に PDFも 達人出版会から買えます)

この本の読み方

問題解決

最初から読む必要はありません。テーマー毎にチャプターになっていますので、興味のあるところ、今作っているアプリが思うように動かない時に関連しそうなチャプターを読むと良いと思います。

内容は、かなり細部まで書かれています。 特に自分の作っているアプリのUIが思っているように動作しない時や、他のアプリで実現されている機能がどうやったら実現できるのか判らない時には StackOverflow や Qiita のような回答そのものではなく、iOSの原理や設計思想などの基本部分に付いても学べます。

スキルアップ

ネット上の情報には偏りがあります。また、UIに付いての解説はたくさんの概念図やスクリーン画像、適切なサンプルコードが必要になるので日本語での良質な情報はネット上には少ないと思います。

本書のような本を読むことは、自分の中で欠けている知識の補足になりますし、あらためてiOSの原理や設計思想などの基本部分を学ぶ事で開発力を高める事ができます。

また、分かりにくい Text Kit や UITableView、UICollectionView の高度な使い方も解説されています。

SwiftからEvernote APIを使うのが予想外に面倒だったのでまとめました

SwiftEvernote APIを使うiOSアプリを作ろうとして、なかなか上手く行かなかったので、今回行った手順をまとめておきました。

基本的には Using the Evernote API from Swift に書かれた手順ですが、上手く行かなかったので少し追加しました。

手順

1. Evernote Cloud SDK for iOS の追加

GitHubのEvernote Cloud SDK for iOS を submodule としてプロジェクトに追加します

%  git submodule add https://github.com/evernote/evernote-cloud-sdk-ios.git
Cloning into 'evernote-cloud-sdk-ios'...
...
% git submodule
 804c0ff4221a430fa5e9619ca717503abdad92c8 evernote-cloud-sdk-ios (heads/master)

2. プロジェクトへ追加

の2つをプロジェクトに追加します

f:id:yuum3:20141216112243p:plain

3. 依存ライブラリーの追加

Getting Started with the Evernote Cloud SDK for iOS に書かれているように、以下の2つのフレームワーク・ライブラリーを追加します。

  • MobileCoreServices.framework
  • libxml2.dylib

f:id:yuum3:20141216113057p:plain

4. ブリッジヘッダーファイルを書く

SwiftからObjective-Cで書かれたライブラリーを呼び出すには、ブリッジヘッダーファイルを作る必要があります。ブリッジヘッダーファイルは半自動で出来るように書かれた ドキュメント もありますが、出来なかったので手動でつくりました。

f:id:yuum3:20141216143646p:plain

  • EvernoteSample-Bridging-Header.h の内容
#import <ENSDK/ENSDK.h>
  • Build Settings

f:id:yuum3:20141216144030p:plain

EvernoteSample/EvernoteSample-Bridging-Header.h

5. Header Search Path

ここでコンパイルしてもエラーが出ます。Evernote Cloud SDK for iOS や libxml2 のヘッダーファイルが見つからないからです。設定しましょう

f:id:yuum3:20141216144543p:plain

${PROJECT_DIR}/evernote-cloud-sdk-ios/evernote-sdk-ios

/usr/include/libxml2

6. Prefix Header の設定

ここまでで Using the Evernote API from Swift に書かれた設定は全て出来たはずですが、以下のような意味不明なエラーが出て悩みました・・・

f:id:yuum3:20141216145210p:plain

そしてGoogle先生にたずねながら理解しました。Evernote Cloud SDK for iOS内には独自のPrefix Header evernote-sdk-ios-Prefix.pch が在るのですが、これが参照されてないようです

f:id:yuum3:20141216145224p:plain

そこで Prefix Header を設定。 これでコンパイルエラーが無くなりました ^^)/

f:id:yuum3:20141216145732p:plain

${PROJECT_DIR}/evernote-cloud-sdk-ios/evernote-sdk-ios/evernote-sdk-ios-Prefix.pch

サンプルコード

この記事を書くために Evernote Cloud SDK for iOSを使いノートを作成する簡単なテストコードを作りました、コードは GitHub にあります。

import UIKit

class ViewController: UIViewController {
    let CONSUMER_KEY    = "YOUR CONSUMER_KEY"
    let CONSUMER_SECRET = "YOUR CONSUMER_SECRET"

    override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated)
        postTestNote()
    }
    
    private func postTestNote() {
        ENSession.setSharedSessionConsumerKey(CONSUMER_KEY, consumerSecret: CONSUMER_SECRET,
            optionalHost: ENSessionHostSandbox)
        
        var session = ENSession.sharedSession()
        if session.isAuthenticated {
            var note = ENNote()
            note.title = "Test"
            note.content = ENNoteContent(string: "test test test ...")
            session.uploadNote(note, notebook: nil, completion: { noteRef, error in
                if error == nil {
                    println("OK")
                } else {
                    println("Upload note error: \(error)")
                }
            })
        } else {
            session.authenticateWithViewController(self, preferRegistration: false, completion: { error in
                if error == nil {
                    self.postTestNote()
                } else {
                    println("Authentication error: \(error)")
                }
            })
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}
  • CONSUMER_KEY/CONSUMER_SECRET は https://dev.evernote.com/intl/jp/doc/ から取得して下さい
  • 作成されるのは sandbox.evernote.com の方です、ノートブックはデフォルトが使われます
  • 画面が表示された瞬間(viewDidAppear)にノートをEvernoteに作ります
  • 最初に起動した際に認証画面が表示されます

UIVewのanimateWithDuration〜メソッドを他のUI更新と一緒に実行してはいけない!

昨晩から、約4時間をこの問題の解決に費やしてしまいまいました 。。。。他の人が同じ間違いしないように記録しておきました。

あるアプリに下のような動きをする、簡単なアニメーションを付けました(この画像、コードは説明用サンプルです)。

f:id:yuum3:20141128153650g:plain

失敗のはじまり

コードは以下のように書きました。

@interface ViewController ()
@property (weak, nonatomic) IBOutlet UILabel *scrollLabel;
@property (weak, nonatomic) IBOutlet UILabel *statusLabel;
- (IBAction)touchStartButton:(id)sender;
@end

@implementation ViewController

- (IBAction)touchStartButton:(id)sender {
    _statusLabel.text = @"scrolling...";
    [UIView animateWithDuration:2.0 animations:^{
        _scrollLabel.center = CGPointMake(_scrollLabel.frame.size.width * -0.5, _scrollLabel.center.y);
    }];
}

このコードは、 statusLabel に scrolling... と表示してから、scrollLabel(黄色いバックグラウンドのラベル)を 2秒かけて、左端に追い出します。

しかし、動かすと下のように動作します ^^; scrollLabelのY座標は、画面の中央にあるはずなのに、なぜかアニメーション開始時右端からはじまります (>_<)

f:id:yuum3:20141128153636g:plain

実際のアプリは、こんなにシンプルではないので、いろいろと調べてみましたが・・・・・。 また AutoLayoutだとアニメーションが思ったように動作しないなど記事を見つけ、また、あーだーこーだー・・・。 ちなみに上のサンプルは AutoLayoutは使っていません。

解決編

そこで、シンプルなサンプルを作り実験してみました。すると正しく動作するコードが判りました!! なんと、 statusLabel に @"scrolling..." を代入するのを止めれば良いのです。

あああ! やっと判りました。それをタイトル「UIVewのanimateWithDuration〜メソッドを他のUI更新と一緒に実行してはいけない!」にしました。 iOSでのほとんどの画面操作API等は、呼び出した(statusLabelへの代入も同じく)瞬間に実行されるのではなく、イベント待ちに戻った時に実行されます。上のコードではアニメーションとstatusLabelの表示変更が同事(?)に行われアニメーションが正しくない動作になってしまうのです。

そこで、以下のコードのようにstatusLabelへ代入を行った後でアニメーションを始めれば正しい動作になるのです。(^○^)

#import "ViewController.h"

@interface ViewController ()
@property (weak, nonatomic) IBOutlet UILabel *scrollLabel;
@property (weak, nonatomic) IBOutlet UILabel *statusLabel;
- (IBAction)touchStartButton:(id)sender;
@end

@implementation ViewController

- (IBAction)touchStartButton:(id)sender {
    _statusLabel.text = @"scrolling...";
    [self performSelector:@selector(scrolling) withObject:nil afterDelay:0.1];
}

- (void)scrolling {
    [UIView animateWithDuration:2.0 animations:^{
        _scrollLabel.center = CGPointMake(_scrollLabel.frame.size.width * -0.5, _scrollLabel.center.y);
    }];
}

@end

LoopBack (Open SourceのBaaS)を使ってみよう! その1. PostgreSQL接続とTwitter認証

あるiOSアプリを作ろうとしています、このアプリはバックエンドのサーバーと情報をやり取りします。バックエンドのサーバーはRuby on Railsで作ってもよいのですが、なるべく早くプロトタイプを動かしたいので、今回は既存のBaaS(Backend as a Service)を使ってみる事にしました。

LoopBack Architecture!

1. なぜ LoopBack を使う事にしたのか

現在BaaSのサービス、Open Sourceのソフトはたくさんあります。ネット上でいろいろと調べてみると BaaS にもサービスよりのものとプラットフォームよりのものがあります(この分類は、私がそう思っているだけかもしれません)。

サービスより のもとしては、実際に良く使われている Parse があげられます。Parseはプッシュ通知やアプリのデータ同期・共有などを複数のプラットフォーム(iOS,Android,PC...)に提供するサービスです。

プラットフォームより のものとしては、Loopbackや以前は話題になったdeploydなどがあります。これらはスマフォとやり取りするAPIサーバーを構築するためのフレームワーククラウドです。 LoopbackはOpen Sourceのソフトですが、それをクラウドで提供するStrongLoopのような会社もあります。

BaaSは現在も進化・競争している世界で、どのサービス・ソフトが良いのかは 簡単には決められない世界のようです。詳しくはBaaSはまだ戦国時代だったのか、まとめ。(執筆中)が参考になります。

今回、Loopbackを使う事にした理由は

  • プラットフォームよりで、今回作るアプリに適切
  • Open Sourceなので、突然のサービス停止にも泣かない
  • ドキュメントもそれなりにある
  • いろいろとサンプルコードがある
  • Node.jsベース。Node.jsはまだまだ初心者ですが Ruby/Railsの世界感にも近い
  • サーバーを自前で準備する必要があるが、このへんは慣れいてる

2. LoopBack入門

a. 前提条件

LoopBackを使うには、Node.js を使って簡単なWebアプリを作れる知識・経験が必要だと思います。

  • JavaScript
  • Node.js
  • npm コマンド
  • 英語ドキュメントが読める (LoopBackの日本語の情報はほぼありません)

b. 入門

  1. node.jsのインストール (v0.10.xを使って下さい。v0.11.xでは動きません)
  2. LoopBackのGetting startedページを試す
    • Ruby on Railsの scaffold のようなものです
    • Modelを定義すると、そのモデルのCRUD操作が出来るREST APIサーバーが出来ます
    • プロジェクトの構成、設定ファイルの役割等を知って下さい
  3. ドキュメントを読みましょう(先にExamplesを試しても良いですが、たぶん読まないと詰まります)
  4. LoopBackのExamplesの興味があるものを試す
    • Examplesのリンク先はGitHubです
    • 詳しい手順が書かれています。たいてい git clone して設定ファイル等を変更し実行する流れです
    • 手順通りにやっても動かない事もままあります。がんばって対応しましょう、とても勉強になります ^^;
    • コードはJavaScriptで書かれています、問題になっているコードを読んでみましょう

3. LoopBackをPostgreSQLにつなぐ

概要

LoopBackはいろいろなDBと接続出来るようになっていますが、大きく2つのタイプがあります

  • MongoDBのようなダイナミックDB
    • このタイプのDBを使う場合はModelにはプロパティー(カラム)の定義が不要です
    • Examplesで使われている Memory もこのタイプです
    • Node.js的にはこのタイプのDBを使うのがお手軽でよいのかもしれません
    • RDBを使う場合も、まず Memory 等で動作を確認してからRDBに置き換えると良いと思います
  • RDB
  • 先ずはGitHubloopback-example-databaseをcloneして動かしてみるのが良いと思います
  • ドキュメントはData sources and connectorsPostgreSQLをつかうならPostgreSQL connector

設定ファイル

LoopBackではDBと接続するための設定ファイルがいくつかあります

  • server/datasources.json : 接続するDBの定義ファイル。
    • 複数のDBを使う事が出来ます
    • Memory DBは最初から定義されています
    • PostgreSQLの定義ファイルにはDatabase名、login、passwordやサーバーなどの情報を書きます、他のソフトでもよくあるものだと思います。
{
  "db": {
    "name": "db",
    "connector": "memory"
  },
  "pgDB": {
    "name": "pgDB",
    "connector": "postgresql",
    "port": 5432,
    "debug": true,
    "database": "node_test",
    "username": "node_test",
    "password": "node_test"
  }
}
  • server/model-config.json: Modelの一覧と、Modelが使うDBなどの定義。
    • 最初の _meta の sources は Modelファイルのある場所のリスト
{
  "_meta": {
    "sources": [
      "../common/models",
      "./models",
      "./node_modules/loopback-component-passport/lib/models"
    ]
  },
  "user": {
    "dataSource": "pgDB",
    "public": true
  },
    ....
  "Role": {
    "dataSource": "db",
    "public": false
  }
}
  • common/models/Model名.json : Modelの定義
    • 対応するRDBのテーブル名、カラム名、型や関連などを定義します
    • optionsを指定する事で、細かいテーブル・カラムの設定が書けます
    • 定義の意味はData sources and connectorsと下位のページに書かれていますが、全てが網羅された情報は無いようです(?)
    • デフォルトでは、Model名、プロパティー名がテーブル名、カラム名にそのまま使われます。対応を変えたい場合はoptionsで指定する必要があります
    • テーブルにはプライマリーキーが必要です、idInjectionをtrueにすればRailsのように整数型のidカラムが自動生成されます
    • 関連情報relationsにはRails同様に hasMany, belongsTo が定義出来ます
{
  "plural": "users",
  "base": "User",
  "options": { "postgresql": { "table": "users" } },
  "idInjection": true,
  "properties": {
    "username": {
      "type": "String",
      "required": true
    },
    "password": {
      "type": "String",
      "required": true
    }
  },
  "relations": {
    "accessTokens": {
      "type": "hasMany",
      "model": "accessToken",
      "foreignKey": "userId"
    }
  },
  "validations": [],
  "acls": [],
  "methods": []
}
  • 他の例
    • 特定のカラムをプライマリーキーにしたい場合は、idInjectionをfalseにし、プロパティーにid属性を指定をします。
{
  "name": "accessToken",
  "plural": "accessTokens",
  "base": "AccessToken",
  "options": { "postgresql": { "table": "access_tokens" } },
  "idInjection": false,
  "properties": {
    "id": {
      "type": "String",
      "id": 1,
      "required": false
    },
    "ttl": {
      "type": "Number",
      "required": false
    },
    "userId": {
      "type": "Number",
      "postgresql": { "columnName": "user_id" },
      "required": false
    },
    "created": {
      "type": "Date",
      "required": false
    }
  },
  "validations": [],
  "relations": {},
  "acls": [],
  "methods": []
}
  • server/create-test-data.js : テーブルの作成とテストデータの作成
    • このファイルは自動生成されないので、Exampleからコピーして作る必要があります。
var server = require('./server');
var dataSource = server.dataSources.accountDB;
var Account = server.models.account;
var accounts = [
    { email: 'foo@bar.com',
      created: new Date(),
      modified: new Date()
    }, {
      email: 'bar@bar.com',
      created: new Date(),
      modified: new Date()
    } ];

var count = accounts.length;
dataSource.automigrate('account', function(er) {
  if (er) throw er;
  accounts.forEach(function(account) {
    Account.create(account, function(er, result) {
      if (er) return;
      console.log('Record created:', result);
      count--;
      if(count === 0) {
        console.log('done');
        dataSource.disconnect();
      }
    });
  });
});

4. LoopBackでTwitter/Facebookの認証を使う

概要

LoopBackにはThird-party loginとい機能があり、これを使うと簡単にTwitter/Facebookの認証を使う事ができます。

設定ファイル

設定ファイルは providers.json で loopback-example-passportには providers.json.template が用意されているのでコピーして使ってください。

{
  "local": {
    "provider": "local",
    "module": "passport-local",
    "usernameField": "username",
   ...
  "twitter-login": {
      "provider": "twitter",
      "authScheme": "oauth",
      "module": "passport-twitter",
      "callbackURL": "http://localhost:3000/auth/twitter/callback",
      "authPath": "/auth/twitter",
      "callbackPath": "/auth/twitter/callback",
      "successRedirect": "/auth/account",
      "consumerKey": "ここにconsumerKeyを指定",
      "consumerSecret": "ここにconsumerSecretを指定"
  },
    ....
}

5. LoopBackでPostgreSQL接続とTwitter認証

PostgreSQLに接続し、Twitter認証が行えるようにしてみました。 GitHubloopback-example-passportをPostgreSQL対応した例 を置きました、これを参考にして下さい。

  • PostgreSQLのテーブル定義は、後々の事を考えRuby on Railsのルールに合わせました
  • providers.jsonTwitterの consumerKey, consumerSecretを入手し作って下さい
  • server/create-test-data.js を実行するとテーブルが作成されます
  • loopback-example-passportは Memoryを使っているので、user モデルにカラム定義がありませんが server/datasources.json に file属性を指定するとDBをファイルにJSONで保存してくれるのでそれを見てカラム定義は作りました
  • user モデル以外の認証に必要なモデルの定義はThird-party+loginに書かれています

ドキュメントには書かれてない部分があったり、callbackの中で起こるエラーはtracebackが在っても役に立たず苦労しました ^^);

Jekyllを1.5.1から2.3.0にアップデートした

私は会社のホームページや教育で使うテキストはJekyllを使って作っています。

最近ずっとアップデートしてなかったので、久しぶりにアップデートしてみました。使っていたのは 1.5.1 で現在の最新は 2.3.0 とメジャー・バージョンアップになるので少し不安でしたが、とくに問題なくアップデートできました。

Jekyll

アップデート手順

通常のRubyプログラムのアップデートと同じです。アップデートでJekillの生成するHTMLがおかしくなってないか確認するために現在の変換結果 _site/以下を保存しておき、アップデート後diffを見られるようにしたくらいです。

% mv _site _site.0     # 現バージョンの変換結果を保存
% mkdir _site
% bundle update      # Jekyllと関連するgemはbundleで管理しています (中身は jekyllとRedCloth)
Fetching gem metadata from https://rubygems.org/........
Resolving dependencies...
Using RedCloth 4.2.9
Using blankslate 2.1.2.4
...
Installing redcarpet 3.1.2 (was 2.3.0)
Using safe_yaml 1.0.3 (was 1.0.1)
Using parslet 1.5.0
Using toml 0.1.1
Installing jekyll 2.3.0 (was 1.5.1)
Using bundler 1.6.2
Your bundle is updated!
% rbenv rehash
% jekyll -v
jekyll 2.3.0
% jekyll server --watch --port 4000  # Jekyllを起動すると幾つかのワーニングが
Configuration file: /Users/yy/Documents/jekyll-ey-office/_config.yml
       Deprecation: The 'pygments' configuration option has been renamed to 'highlighter'. Please update your config file accordingly. The allowed values are 'rouge', 'pygments' or null.
            Source: /Users/yy/Documents/jekyll-ey-office
       Destination: /Users/yy/Documents/jekyll-ey-office/_site
      Generating... 
     Build Warning: Layout 'nil' requested in atom.xml does not exist.
                    done.
 Auto-regeneration: enabled for '/Users/yy/Documents/jekyll-ey-office'
Configuration file: /Users/yy/Documents/jekyll-ey-office/_config.yml
       Deprecation: The 'pygments' configuration option has been renamed to 'highlighter'. Please update your config file accordingly. The allowed values are 'rouge', 'pygments' or null.
    Server address: http://0.0.0.0:4000/
  Server running... press ctrl-c to stop.
% 

ワーニングに対応

  • _config.yml これはワーニングメッセージにある通り修正
-pygments: false
+highlighter: :pygments
-layout: nil
+layout: null

レイアウト無しがの指定が nil から null に変更されたのですね (ナゼ^^;)

確認

RSSの作成日付が変わったのみで、生成されるHTMLはまったく同じでした!

% jekyll server --watch --port 4000
...
^c
% diff -r _site.0 _site
diff -r _site.0/atom.xml _site/atom.xml
7c7
<  <updated>2014-09-08T16:19:29+09:00</updated>
---
>  <updated>2014-09-08T17:32:18+09:00</updated>

独自の_plugin(マクロ)も使っていますが、問題ありませんでした。

変更履歴に付いて気が付いた事

昨日、数ヶ月前にリリースしたソフトの滅多に使わない部分にバグが見つかり修正したのですが、その際に気が付いた事があったので書いてみました。

f:id:yuum3:20140902095352j:plain

このソフトは、簡単な業務システムでRuby on Railsで作られています。利用者がかんちがいや間違ってデータを変更してしまった際に、管理者がデータを戻せるように PaperTrail というgem を使ってテーブルの変更履歴を残しています。

システムにはいくつかのテーブルがあり利用者が作成・変更するデータが入るテーブルとプログラムが生成・変更するテーブルがあります。システム設計の時点では上に書いたように誤操作からのリカバーを意図したので、PaperTrail で履歴を残すテーブルは利用者が作成・変更するテーブルのみにしてありました。

今回のバグで作成されたデータの入っているテーブルをリカバーしようと管理画面 RailsAdminで見てみると、なんと履歴がありません・・・・。 なぜ? と思い考えたところで最初に書いた設計時の判断を思い出しました。

このようにソフトにバグがあってテーブルに思わぬ変更が起きる事は想定してなかったのです。今回の経験から全てのテーブルの変更履歴を残した方が良いのではと思い、全てのモデル(テーブル)にPaperTrail を定義しました。

ちなみ、このシステムは少人数がたまに使うシステムなので、全てのモデル(テーブル)で履歴管理する事は全く問題がありません。

SwiftでiTunes ライブラリファイルを編集するMac OS X アプリを作ってみた

一昨日のMP3ファイルのID3タグを一度に変更できるツールを作ってみた に続き、iTunes ライブラリファイル (iTunes Music Library.xml) を編集するMac OS X アプリをSwiftで作ってみたので、簡単にSwift言語とQuickというBDDフレームワークに付いて書きます。

コードはGitHubに置きました 。Xcode6 beta6で動作確認しています

f:id:yuum3:20140822100446p:plain

このアプリの使い方

起動するとTunes ライブラリファイルiTunes Music Library.xmlを読み込み上のような画面が表示されます。Alubm Title名を入力しSearchボタンを押すと、そのアルバムの曲がテーブルに表示されます。ここでタイトルを変更したい場合は、AmazonでCDを検索し下のような曲名一覧をコピーし、テキストビューにペーストしてReplaceボタンを押すと、アプリ内の曲名情報が更新されます。そしてメーニューのSaveを選択するとデスクトップに更新されたiTunes Music Library.xmlファイルと 一昨日ツールで使う曲名情報のファイル MpegTitleList.txt が保存されます。

f:id:yuum3:20140822111906p:plain

注意 このアプリにはバグがあるかもしれませんので、実際に使う際には絶対に iTunes Library.itl , iTunes Music Library.xml のバックアップを取ってから使って下さい m(__)m

解説

iTunes Music Library.xml に付いて

iTunesで管理している曲の情報は iTunes Library.itl ファイルで管理されていますが、その内容をXML形式で書き出したファイルが iTunes Music Library.xml です。iTunes Library.itlの全ての情報は含まれていないようですが、ほぼ全てと考えて良いと思います。iTunes ライブラリおよびプレイリストを作成し直す方法iTunes Library.itl ファイルが壊れてしまった際に iTunes Music Library.xml から復旧する方法が書かれています。

iTunes Music Library.xmlNextSTEP由来のオブジェクトをシリアライズしたplistファイルで今風に言えばJSONみたいなものです、Mac OS X, iOSで設定情報を保存するによく使われています。 詳細はWikipedaのプロパティリスト したがって、Mac OS X, iOSCocoaフレームワークのクラス(例 NSDictonary)から簡単に読み書きできます。

ITunesLibraryクラス

iTunes Music Library.xmlの読み書き、検索、置換を行うクラスです。Swift言語はC, Objectve-C, Java, Rubyなどの言語になれている人ならコードの大部分は理解できると思います。ここでは Swiftらしい部分のみ解説してみます。

まずは内部で使う型を定義しています。 Swiftでは構造体が使えるので簡単なデータ構造は struct で定義するのがお手軽です。また struct にはメソッドが定義できるので、ここではデバッグ等で便利なように descriptionメソッドを定義しています(しかし現在はdescriptionメソッドを明示しないと行けません!?)

import Cocoa

struct MpegFileTitle : Printable {
    let path: String
    let title: String
    var description: String { get {return "path: \(self.path) title: \(self.title)"} }  // Not work as "Swift Standard Library Reference"
}

typealias TrackId = Int

定数(class var 〜 { get 〜})の定義とインスタンス変数の定義(var 〜)。Swiftのクラスに対しての定数は書けない(インスタンスから参照出来る定数は let で書けます)ので参照のみのクラスComputed Propertiesを使っています。(もっと良い方法があるかも、ちなみに class let と書くとnot yet supportedエラーになります)

class ITunesLibrary: NSObject {
    class var LibraryXmlFileName: String { get { return "iTunes Music Library.xml" } }
    class var MpegTitleFileName: String { get { return "MpegTitleList.txt" } }
    
    var libaryDict: NSMutableDictionary = [:]
    var mpegTitleList: [MpegFileTitle] = []

XmlFilePath()はclass funcから始まるのでクラスメソッドです

    class func XmlFilePath() -> String {
        return NSHomeDirectory() + "/Music/iTunes/iTunes Music Library.xml"
    }

さて次は、iTunes Music Library.xmlの読み書きするメソッドです。 Swiftの特徴の一つOptionalが使われています。Swiftは強い型付けの静的言語で、通常に宣言した変数は nil (値無し)には出来ません。nilになる可能性のある変数は、型の後ろに ? を付けて宣言します(または !を付ける、少し動作が違います)。また、コードの中で変数に格納されたオブジェクトのメソッドを呼び出す際には、nil でないことを保証するコードにする必要があります。CやObjective-Cに慣れているととても面倒に感じますが、nullポインターエラーにならない安全なコードがかけます。ただし、変数名! と書くと面倒な処理を省略できますが、値がnilの場合は実行時エラーになります。

loadの処理はNSData.dataWithContentsOfFile()メソッドでファイルを読み込み、NSPropertyListSerialization.propertyListWithData()メソッドでplist(XML)をSwiftのオブジェクトに変換しています。saveはその逆の操作をしています。

ちなみに、 NSDictionary(contentsOfFile: path)で直接plistファイルを読み込めますが、エラーがあった場合のエラー情報が取得出来ないのでこのようにしました。 詳しくはこちら

またsaveの中では、曲名情報のファイル(mpegTitlesFile)の作成ではArray#mapやString#joinなどの便利なメソッドを使っています。詳しくはこちら

    func load(libraryXmlPath: String) -> NSError? {
        var error:NSError?;

        let data = NSData.dataWithContentsOfFile(libraryXmlPath, options: nil, error: &error)
        if data == nil {
            return error
        }
        let plist = NSPropertyListSerialization.propertyListWithData(data,
            options: 2, format: nil, error: &error) as NSMutableDictionary!
        if plist == nil {
            return error
        }
        libaryDict = plist
        mpegTitleList = []
        return nil
    }
    
    func save(saveFolderPath: String) -> NSError? {
        var error:NSError?;

        let data = NSPropertyListSerialization.dataWithPropertyList(libaryDict,
            format: NSPropertyListFormat.XMLFormat_v1_0, options: 0, error: &error)
        if data == nil {
            return error
        }
        if !data.writeToFile(saveFolderPath.stringByAppendingPathComponent(ITunesLibrary.LibraryXmlFileName),
            options: NSDataWritingOptions.AtomicWrite, error: &error) {
            return error
        }

        var mpegTitlesFile = "\n".join(mpegTitleList.map({"\($0.path)\t\($0.title)"})) + "\n"
        if !mpegTitlesFile.writeToFile(saveFolderPath.stringByAppendingPathComponent(ITunesLibrary.MpegTitleFileName),
            atomically: true, encoding: NSUTF8StringEncoding, error: &error) {
            return error
        }

        return nil
    }

searchAlubm()は指定されたアルバムタイトルの曲を検索するメソッドです、単純に全ての曲を調べるので遅いかもしれません。前にも書いたようにSwiftは強い型付けの静的言語なので NSDictionaryのように何型のデータ(AnyObject!型)が入っているか判らないと処理が書けなので as 〜 で型をキャストしています。ただし、SwiftのStringとNSString等はある程度は自動的に変換されます。

SwiftのArrayやDictionaryは宣言時に型を指定しますが、NSArrayやNSDictionaryを扱う場合はキャストが必須です。キャストする際にもnil状態(Optiontal)を考えないといけません。ただしコンパイラー(Xcode)が警告やエラーを出すので助かります。

ここでも Array#sortのようにクロージャー(ブロック)を受け取るメソッドを使っています。

    func searchAlubm(title: String) -> [TrackId] {
        var trackIds : Array<TrackId> = []
        let tracks = libaryDict["Tracks"] as NSDictionary
        for (key, dict) in tracks {
            if (dict["Album"] as String?) == title {
                trackIds.append((key as String).toInt()!)
            }
        }
        trackIds.sort { $0 < $1 }
        return trackIds
    }
    
    func songTitle(id: TrackId) -> String {
        let tracks = libaryDict["Tracks"] as NSDictionary
        let track = tracks[String(id)] as NSDictionary
        return track["Name"] as String
    }

SwiftObjective-C同様に正規表現の組み込みクラスやリテラルはありません。メソッドも引数がたくさんあり使いにくいです ^^;

    class func parse(titlesChunk: String) -> [String] {
        var titles: [String] = []
        let regex = NSRegularExpression.regularExpressionWithPattern("\\d+\\.\\s*(.*?)(\\s*試聴する)?$", options: nil, error: nil)
        
        for line in titlesChunk.componentsSeparatedByString("\n") {
            if var matches = regex?.firstMatchInString(line, options: nil, range: NSMakeRange(0, countElements(line))) {
                titles.append((line as NSString).substringWithRange(matches.rangeAtIndex(1)))
            }
        }
        
        return titles
    }
    
    func replaceTiles(ids: [TrackId], _ titles: [String]) -> NSError! {
        if ids.count != titles.count {
            return NSError.errorWithDomain("The number of titles is not the same as the number of tracks", code: 10001, userInfo:  nil)
        }
        let tracks = libaryDict["Tracks"] as NSDictionary

        for id in ids {
            if tracks[String(id)] == nil {
                return NSError.errorWithDomain("TrackId \(id) not found", code: 10002, userInfo:  nil)
            }
        }

        for var i = 0; i < ids.count; i++ {
            var track = tracks[String(ids[i])] as NSMutableDictionary
            track["Name"] = titles[i]
            mpegTitleList.append(MpegFileTitle(path: NSURL.URLWithString(track["Location"] as String).path!, title: titles[i]))
        }
        
        return nil
    }
}

ITunesLibraryクラスのテストコード

テストはXcode標準のXCTestではなく、RSpec風に書ける QuickというBDDフレームワークを使いました。ただし8/22日時点で QuickはXcode6 beta6 に対応してないので フォークの bendjones/Quickを使いました。 (/Quick/Quickもbeta6対応されました 8/23)

最近はどの言語にもRSpec風に書けるツールがあり、新しい言語を学ぶにもテスト駆動開発が役に立つと思います。

コードはRSpecになれている方なら、だいたい読めると思います。Swift言語に付いてコメントすると

  • 基底クラスのメソッドを置き換えるするには override を書く必要があります
  • メソッドの戻り値を無視する事は出来ないので。 var _ = method() のように書きました(もっと良い書き方がるのかな?)
  • ヒアドキュメントや、文字列を改行したりは出来ないので文字列を改行したい場合は + で連結して下さい
import Quick
import Nimble

class ITunesLibrarySpec: QuickSpec {
    override func spec() {
        var lib: ITunesLibrary =  ITunesLibrary()
        var loadeErr: NSError!
        beforeEach {
            loadeErr = lib.load("./iTunesTitleRepairerTests/iTunesLibrary.xml")
        }
        describe("#load") {
            it("iTunesLibary XMLファイルを読み込める") {
                expect(loadeErr).to(beNil())
                expect(lib.libaryDict.count).to(equal(9))
                expect(((lib.libaryDict["Tracks"] as NSDictionary).allKeys as [String]).count).to(equal(4))
            }
            it("iTunesLibary XMLファイルが存在しない場合はErrorを戻す") {
                let err = lib.load("./iTunesTitleRepairerTests/NOiTunesLibrary.xml")
                expect(err!.localizedDescription).to(contain("no such file"))
            }
        }
        describe("#save") {
            let NewLibFolder = "/tmp"
            let NewLibPath = NewLibFolder.stringByAppendingPathComponent(ITunesLibrary.LibraryXmlFileName)
            let MpegTitlesPath = NewLibFolder.stringByAppendingPathComponent(ITunesLibrary.MpegTitleFileName)
            
            let fileMan = NSFileManager.defaultManager()
            beforeEach {
               var _ = fileMan.removeItemAtPath(NewLibPath, error: nil)
            }
            it("ファイルに保存出来る") {
                expect(lib.save(NewLibFolder)).to(beNil())
                expect(fileMan.fileExistsAtPath(NewLibPath)).to(beTruthy())
                expect(fileMan.fileExistsAtPath(MpegTitlesPath)).to(beTruthy())
            }
            it("変更内容が書き込まれている") {
                var _ = lib.replaceTiles([6257, 6259], ["名もない恋愛", "足ながおじさんになれずに"])
                var _ = lib.save(NewLibFolder)
                
                var newLib: ITunesLibrary =  ITunesLibrary()
                newLib.load(NewLibPath)
                expect(newLib.songTitle(6257)).to(equal("名もない恋愛"))
            }
            it("音楽ファイル名変更用shell scriptも書かれる") {
                var _ = lib.replaceTiles([6257, 6259], ["VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ", "SARAVAH! / サラヴァ!"])
                var _ = lib.save(NewLibFolder)
                
                expect(NSString(contentsOfFile: MpegTitlesPath, encoding: NSUTF8StringEncoding, error: nil)).to(equal(
                    "/Users/yy/Music/iTunes/iTunes Media/Music/高橋幸宏/Saravah!/01 AudioTrack 01.mp3\tVOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ\n/Users/yy/Music/iTunes/iTunes Media/Music/高橋幸宏/Saravah!/02 AudioTrack 02.mp3\tSARAVAH! / サラヴァ!\n"))
            }
        }
        describe("#searchAlubm") {
            it("アルバム名で検索し曲名のTrackIDを戻す") {
                expect(lib.searchAlubm("A Day in the next life")).to(equal([5791, 5793]))
            }
        }
        describe("#songTitle") {
            it("指定されたTrackIDの曲名を戻す") {
                expect(lib.songTitle(5791)).to(equal("震える惑星(ほし)"))
            }
        }
        describe(".parse") {
            it("Amazonの曲名リストから曲名の配列を取得する") {
                let chunk = "1. VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ\t試聴する\n" +
                    "2. SARAVAH! / サラヴァ!\t試聴する\n" +
                    "1. 名もない恋愛\n" +
                    "2. 足ながおじさんになれずに"
                expect(ITunesLibrary.parse(chunk)).to(equal(
                    ["VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ", "SARAVAH! / サラヴァ!", "名もない恋愛", "足ながおじさんになれずに"]))
            }
        }
        describe("#replaceTiles") {
            it("指定された複数のidの曲名を置き換える") {
                let err = lib.replaceTiles([6257, 6259], ["VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ", "SARAVAH! / サラヴァ!"])

                expect(err).to(beNil())
                expect(lib.songTitle(6257)).to(equal("VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ"))
                expect(lib.songTitle(6259)).to(equal("SARAVAH! / サラヴァ!"))
                println(lib.mpegTitleList[0].description)
            }
        }
    }
}

AppDelegateデリゲート

GUI操作に対応したコードは今回は全てAppDelegateクラスに書いてしまいました。iOSプログラミングをしたことがある方は、だいたいわかると思います。

import Cocoa


class AppDelegate: NSObject, NSApplicationDelegate, NSTableViewDataSource, NSTableViewDelegate {
                            
    @IBOutlet weak var window: NSWindow!
    @IBOutlet weak var albumTitle: NSTextField!
    @IBOutlet weak var songTable: NSTableView!
    @IBOutlet      var newTitleText: NSTextView!

    var trackIds: [Int] = []
    var iTunes: ITunesLibrary = ITunesLibrary()
    
    let NewFilesFolder = NSHomeDirectory() + "/Desktop"
    
    func applicationDidFinishLaunching(aNotification: NSNotification?) {
        performBlock(0.1) { self.openLibrary(ITunesLibrary.XmlFilePath()) }
    }

    func applicationWillTerminate(aNotification: NSNotification?) {
    }

    func numberOfRowsInTableView(tableView: NSTableView!) -> Int {
        return trackIds.count
    }
    
    
    func tableView(tableView: NSTableView!, objectValueForTableColumn tableColumn: NSTableColumn!, row: Int) -> AnyObject! {
        if tableColumn.identifier == "SequenceColumn" {
            return row + 1
        } else {
            return iTunes.songTitle(trackIds[row])
        }
    }

    @IBAction func openDocument(sender: AnyObject) {
        let openPanel = NSOpenPanel()
        openPanel.canChooseFiles = true
        if openPanel.runModal() == NSOKButton && openPanel.URLs.count == 1 {
            openLibrary((openPanel.URLs[0] as NSURL).path!)
        }
    }

    @IBAction func saveDocument(sender: AnyObject) {
        if let err = iTunes.save(NewFilesFolder) {
             showErrorAlert(err)
        }
    }
    
    @IBAction func searchPushed(sender: AnyObject) {
        trackIds = iTunes.searchAlubm(albumTitle.stringValue)
        songTable.reloadData()
    }

    @IBAction func replacePushed(sender: AnyObject) {
        let titles = ITunesLibrary.parse(newTitleText.string)
        if titles.count == 0  { return }

        if let err = iTunes.replaceTiles(trackIds, titles) {
            showErrorAlert(err)
        } else {
            songTable.reloadData()
            newTitleText.string = ""
        }
    }
    
    private func openLibrary(path: String) {
        if let err = iTunes.load(path) {
            showErrorAlert(err)
        } else {
            newTitleText.string = ""
            albumTitle.stringValue = ""
            albumTitle.enabled = true
            trackIds = []
            songTable.reloadData()
        }
    }
    
    private func showErrorAlert(error: NSError!) {
        var alert = NSAlert(error: error)
        if alert.runModal() == NSAlertFirstButtonReturn {
            // NOP
        }
    }

    private func performBlock(deley: Double, _ closure: () -> ()) {
        dispatch_after(
            dispatch_time(DISPATCH_TIME_NOW, Int64(deley * Double(NSEC_PER_SEC))),
            dispatch_get_main_queue(),
            closure)
    }

}

感想

  • Swiftは読み書きし易い、良い言語だと思う
  • 正規表現リテラルやヒアドキュメントが入ると良いなぁ〜
  • Optionalは面倒だけど安全なコードを書くためには良いと思う
  • Swiftは強い型付けの静的な言語だが、Objectvie-Cは弱い型付けでメソッドは動的に決定されるのでObjectve-Cで作られたCocoa(Mac OS X, iOS)のクラスを利用すると途端に煩雑なコードになって行く!
  • iOS, Mac OS Xプログラミングで Cocoaを使わない事はありえないので、もう少し良いソリューションが提供されないのかな・・・・