yo_waka's blog

418 I'm a teapot

WEB+DB PRESS Vol.79にAngularJSの記事を書きました

2/22発売のWEB+DB PRESS Vol.79JavaScript連載で「AngularJS」をテーマに記事を書きました。

こんな感じのことを書いてます。

  • AngularJSについて
  • AngularJSの主な機能
  • AngularJSアプリケーションのファイル構成

AngularJSを使って本格的にアプリケーションを作っていくのに最低限必要な要素について解説しています。
AngularJS触ってみようかなーとか、とりあえずHelloWorldしてみたけどどういう機能があるのかいまいち分からないなー。
といった人のお役に立てれば幸いです。

以下、短いですけど執筆後記です。

2年間続いた「JavaScript活用最前線 -大規模開発の現場から-」は今号で最終回となります。
ちょうど2年ほど前に、現場で使えるJavaScriptネタで連載を書かないかという話をいただいて、最初は僕とid:ama-chの2人で書く予定だったのですが、僕が転職することになったため、急遽同期のid:teppeisにお願いすることになったという経緯があります。
記事のやり取りをしていたサイボウズLiveを見ていて、申し訳ないなという気持ちもあったり。
そんな中、連載が2年目に入ったのと、ちょうど今の会社に転職して個人で執筆できるようになったのもあって、2年目の連載は3人でローテーションして書いてきました。
業務をこなしながら8ページ分の記事を書くのは、業務のタイミング次第では本当に大変でしたが、非常にいい経験ができたと思っています。
id:teppeisid:ama-ch、2年間お疲れさまでした!

iOSアプリの全てのビューコントローラーにGoogleAnalyticsを一括で設定する

今作っているアプリで、改善のためにどれくらい画面が使われているか知りたかったので、GoogleAnalyticsを入れたときのメモ。

GoogleAnalyticsはご存知みんな知っているアクセス解析ツール
iOS用にもSDKが公開されていて、CocoaPodsを使っていればpod installで簡単に入れられる。

pod 'GoogleAnalytics-iOS-SDK', '~> 3.0'

画面の閲覧回数を取るためには、2つやり方がある。

1つは、GAITrackedViewControllerクラスを継承したUIViewControllerを作る。
viewDidLoadなどでscreenNameに画面名をセットしておくと、viewDidAppearで自動でトラッキングリクエストが送信される。

@interface SampleViewController : GAITrackedViewController
@end

@implementation SampleViewController

- (void)viewDidLoad
{
    self.screenName = @"画面名";
}

end

もう1つは、ビューコントローラーのviewDidLoadかviewDidAppearで、GAITrackerクラスを使って画面名を送ってやるやり方。
こっちはWebブラウザ版の使い方に近い。
UITableViewControllerなどUIViewControllerのサブクラスを使っている場合は、こっちでやるしかない。

- (void)viewDidAppear
{
    [super viewDidAppear];

    id tracker = [[GAI sharedInstance] defaultTracker];
    [tracker set:kGAIScreenName value:@"画面名"];
    [tracker send:[[GAIDictionaryBuilder createAppView] build]];
}

つまり、UITableViewControllerをふんだんに使っていたり、UIViewControllerを継承したベースクラスを作っていると、1つ1つのビューに同じ処理を書かないといけない。
これは絶対入れるの忘れそうなのでなんとかしたい。。。と思って調べてみた。

Objective-CにはMethod Swizzlingという、すでに実装されているクラスのメソッドを自前のメソッドに入れ替えるやり方が用意されているらしい。
"objc/runtime.h"が提供している、method_exchangeImplementations関数を使えばクラスメソッドの入れ替えが可能になる。

これを使ってUIViewControllerのメソッドを入れ替えれば各画面ごとにアナリティクス処理を書かずに済みそう。
つまり、UIViewControllerのカテゴリ拡張を作って、viewDidAppearをGATrackerの処理を追加してものに入れ替える関数を用意する。
画面名には「NSStringFromClass([self class])」でクラス名を自動でセットしてやる。

#import <objc/runtime.h>

@implementation UIViewController (GAInject)

- (void)replacedViewDidAppear:(BOOL)animated
{
    // 元のメソッド(名前は既に置き換わっているので注意)を呼び出す
    [self replacedViewDidAppear:animated];
              
    [[GAI sharedInstance].defaultTracker set:kGAIScreenName value:NSStringFromClass([self class])];
    [[GAI sharedInstance].defaultTracker send:[[GAIDictionaryBuilder createAppView] build]];
}

+ (void)exchangeMethod
{
    [self exchangeInstanceMethodFrom:@selector(viewDidAppear:) to:@selector(replacedViewDidAppear:)];
}

/**
 メソッドの入れ替え
  */
+ (void)exchangeInstanceMethodFrom:(SEL)from to:(SEL)to
{
    Method fromMethod = class_getInstanceMethod(self, from);
    Method toMethod   = class_getInstanceMethod(self, to);
    method_exchangeImplementations(fromMethod, toMethod);
}

@end

AppDelegateでこいつを呼び出してUIViewControllerのviewDidAppear関数を入れ替える。

#import "UIViewController+GAInject.h"

@implementation SampleAppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    // UIViewControllerのメソッド差し替え
    [UIViewController exchangeMethod];
}

@end

これで、UIViewControllerを継承しているUITableViewControllerや自作ビューコントローラーでも、自動でトラッキング処理が走るようになります。
method_exchangeimplementations、Rubyのalias_method感覚で使えるヒッジョーに面白い仕組みですが、そのクラスと子クラスすべての挙動が変わるので使いどころには要注意ですね。

Macの環境構築にhomebrew-cask+Brewfile便利

先週 Macbook Air を新しいマシンにリプレースした際に、環境構築どうしようかなーと思って、homebrew-caskを使ってみたらかなり捗った。

Mac上の環境構築でよく聞くのは、GitHubが公開しているBoxenだと思うけど、PuppetのDSL覚えるの面倒くさいし、パッケージ情報をメンテナンスするのも結構ヘビーだったりする。
対してhomebrew-caskは、Homebrewの仕組みを拡張して、GUIアプリも入れられるようにして、全部brewコマンドで管理できるようにしようぜという思想で作られている。

1つずつコマンド打って全部入れていってもいいんだけど、最近のHomebrewはBrewfileを使ってパッケージ管理できるので一発で入れられて便利。

# Make sure using latest Homebrew
update

# Update already-installed formula
upgrade

# Add Repository
tap phinze/homebrew-cask || true
tap homebrew/binary || true

# Packages for development
install zsh
install git
install vim

# Packages for brew-cask
install brew-cask

# .dmg from brew-cask
cask install google-chrome
cask install virtualbox
cask install vagrant

# Remove outdated versions
cleanup

こんな感じのBrewfileがあるディレクトリで、ターミナルから「brew bundle」と打つと、書いたとおりにソフトウェアがインストールされる。
アップデートも「brew update」でよしなにやってくれる。

BrewfileをGithubかなにかで管理しておけば、新しいマシンが来たときは「brew bundle」を実行するだけだし、新しく環境作り直す場合もbrewコマンドで消すかHomebrewのディレクトリをまるごと削除するだけでいいので簡単。

デフォルトの設定だとインストール先は「/opt/homebrew-cask/Caskroom」になり、「~/Applications」にシンボリックリンクを貼る模様。
これを変えたい場合は、「HOMEBREW_CASK_OPTS」という環境変数に指定すれば変更できる。
自分はhomebrew本体と同様にCaskroomを「/usr/local」下に置きたかったのと、シンボリックリンクは「/Applications」に貼りたかったので変更した。

export HOMEBREW_CASK_OPTS="--appdir=/Applications --caskroom=/usr/local/Caskroom"

ちなみに自分はAlfredをランチャーアプリとして使っていて、homebrew-caskで入れたアプリはシンボリックリンクなので検索対象になってくれなかった。
ただ、対応策としてサブコマンドがちゃんと用意されていて、そいつを実行すると検索対象に含まれるようになった。

$ brew cask alfred
$ brew cask alfred link # CaskroomをAlfredの検索パスに追加

AngularJSのディレクティブの仕組みを追ってみた

追ってみたシリーズ第3回目。

AngularJSのディレクティブ、名前は聞いたことあるけどあれでしょ?自前の「ng-hoge」を作るための仕組みでしょ?
だいたいそんな感じですが、どうやって実現しているのか。

ディレクティブの役割

ディレクティブはHTMLビューを書き出すためだけのものじゃない。
「ng-controller」や「ng-model」など、HTMLに対して処理をバインド/注入する機能もディレクティブで出来ている。
AngularJSが持つ組み込みのディレクティブだけでもこれだけの数がある。

まさしくAngularJSのベースとなっている仕組みがDirectiveというわけか。

追ってみる

ベースとなるngDirective関数を見ると、"link"と"restrict"プロパティを持ったオブジェクトを返す関数を作っていることが分かる。

function ngDirective(directive) {
  if (isFunction(directive)) {
    directive = {
      link: directive
    };
  }
  directive.restrict = directive.restrict || 'AC';
  return valueFn(directive);
}

これだけじゃよく分からないので、「ng-model」ディレクティブを見てみる。

var ngModelDirective = function() {
  return {
    require: ['ngModel', '^?form'],
    controller: NgModelController,
    link: function(scope, element, attr, ctrls) {
      // notify others, especially parent forms

      var modelCtrl = ctrls[0],
          formCtrl = ctrls[1] || nullFormCtrl;

      formCtrl.$addControl(modelCtrl);

      scope.$on('$destroy', function() {
        formCtrl.$removeControl(modelCtrl);
      });
    }
  };
};

AngularJSの2way bindingの仕組みを追ってみた」で追っていた初期化処理(bootstrap関数)でcollectDirective関数が呼ばれ、スコープが管理するHTML内で「ng-model」が定義されていると、AngularJS内で「ngModel」をキーをしてマッピングされているngModelDirectiveが実行される。
ここで返されたオブジェクトがディレクティブとなる。
compileプロパティを持たず、linkプロパティを持っている場合は、linkプロパティを返す関数がcompileプロパティとしてディレクティブにセットされる。

このとき、ディレクティブに自動でセットされるプロパティは以下のものになる。

  • compile(ディレクティブの実行内容)
  • priority(DOMに複数のディレクティブが定義されている場合の優先順位)
  • index(DOMに対して何番目に定義されたディレクティブか)
  • name(ディレクティブにつけられている名前)
  • require(依存関係を持つディレクティブ)
  • restrict(ディレクティブの適用条件。"A"なら属性名、"E"なら要素名、"AE"ならどちらにもマッチ)

そして返されたディレクティブは、要素のcompileプロセスにおいて、applyDirectivesToNode関数で要素に対して「POST LINKING」というリンク関数としてセットされる。
リンク関数には「PRE LINKING」「POST LINKING」があるが、これは子要素に対してリンク関数を実行する前に実行するか後に実行するかというフェーズがある。
「PRE LINKING」にするか「POST LINKING」にするかは、ディレクティブを定義する際のcomlileプロパティに"pre"か"post"を指定すれば選択可能。

compile: function compile(scope, element, attr) {
  return {
    pre: function preLink(scope, iElement, iAttrs, controller) { ... },
    post: function postLink(scope, iElement, iAttrs, controller) { ... }
  }
  // or
  return function postLink( ... ) { ... }
}

この後、nodeLinkFnというクロージャ関数でリンク関数としてディレクティブのcompileに指定した処理がようやく実行される。 このとき、ディレクティブにcontrollerプロパティが指定されていれば、コントローラーがインスタンス化され、自分が所属するスコープ内の検索対象として使われる(requireで"^"がついていない場合)。
見つかったコントローラーは、コンパイル時の指定関数の第4引数に配列でセットされ渡される。

ディレクティブでできること

  • 自分が定義した要素名が使えるようになり、その要素に対する初期処理をAngularJS内のフェーズごとに書ける
  • 既存の要素に対し、初期処理をAngularJS内のフェーズごとに書ける
  • 特定の要素、属性を持つスコープやコントローラーに対し、処理を外側から追加することができる

このスコープやコントローラーに処理を追加できるのがキモだと思われます。
独自にAngularJSに機能を追加するプラグインやライブラリを書く場合、「PRE LINKING」で所属するスコープに関数を生やしたり、コントローラーに新たに処理をバインドしたりといったことはディレクティブを使うとよさそうです。

今回はディレクティブの外部テンプレート機能については触れていないので、それはまた次に調べる。

AngularJSのDIの仕組みを追ってみた

AngularJS黒魔術のうちの1つ。DI。

コントローラーの引数に$httpなどを指定すると、なぜ何もしなくてもHttpProviderの返り値が入ってくるのか。

var userControllers = angular.module('userControllers', []);

userControllers.controller('UsersCtrl', function($scope, $http) {
  $http.get('users/index.json').success(function(data) {
    $scope.users = data;
  });
});

これは、定義したコントローラーをインスタンス化する際にannotate関数でDI対象となる引数を取得して、該当するサービスオブジェクトに差し替えているから。

function invoke(fn, self, locals){
  var args = [],
      $inject = annotate(fn),
      length, i,
      key;

  for(i = 0, length = $inject.length; i < length; i++) {
    key = $inject[i];
    if (typeof key !== 'string') {
      throw $injectorMinErr('itkn',
          'Incorrect injection token! Expected service name as string, got {0}', key);
    }
    args.push(
        locals && locals.hasOwnProperty(key)
        ? locals[key]
        : getService(key)
        );
  }
  if (!fn.$inject) {
    fn = fn[length];
  }

  return fn.apply(self, args);
}

では、annotate関数では何をしているのか。
FunctionオブジェクトをtoStringで文字列化して、引数に当たる文字列を抜き出し、返している。
fn.lengthはその関数が受け取る引数の数を表す。

function annotate(fn) {
  var $inject,
      fnText,
      argDecl,
      last;

  if (typeof fn == 'function') {
    if (!($inject = fn.$inject)) {
      $inject = [];
      if (fn.length) {
        fnText = fn.toString().replace(STRIP_COMMENTS, '');
        argDecl = fnText.match(FN_ARGS);
        forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg){
          arg.replace(FN_ARG, function(all, underscore, name){
            $inject.push(name);
          });
        });
      }
      fn.$inject = $inject;
    }
  }
  return $inject;
}

上の例の場合、argDecl[1]には"$scope, $http"という文字列が入る。
これをsplitして、getService関数でファクトリ関数の返り値をキャッシュしたプールからオブジェクトを取り出し、コントローラーのコンストラクタに渡して実行する、というわけか。

このキャッシュプールから取り出すキーになるのが"$http"だったり"$routeParams"だったりするので、引数の名前が違うとDIされない。
Angularが用意しているProviderを使いたければ、Angularが中で持っている名前にしないといけないし、module.factoryなどでユーザーが定義したサービスだったら、その名前で指定しないといけない。

また、よく言われるminifyのための注意として、ユーザーが定義するコントローラーやサービスには文字列でも引数を指定しておくというのも納得。
そうしないとminify時に引数名が短縮されてしまうので、DIすべき対象が見つからなくなってしまう。

userControllers.controller('UsersCtrl', ['$scope', '$http', function($scope, $http) {
  $http.get('users/index.json').success(function(data) {
    $scope.users = data;
  });
}]);