Qiita:TeamのテンプレートとJSONからテキストを組み立てるヤツ

あけましておめでとうございます。

プログラミング初めとしてnaoty/qiita-buildという簡単なrubygemを作りました。Qiita:Teamで下のようなテンプレートがあったときに

# 自己紹介
* 氏名: %{fullname}
* ニックネーム: %{nickname}
* 居住地: %{location}
* 生年月日: %{birthday}

標準入力でJSON文字列を渡すと、変数の中身をそれで展開するだけ。

$ echo '{"fullname": "Naoto Kaneko", "nickname": "naoty", "location": "Tokyo", "birthday": "1987/6/2"}' | qiita-build -t <TEAM> -a <ACCESS TOKEN> <TEMPLATE ID>
# 自己紹介
* 氏名: Naoto Kaneko
* ニックネーム: naoty
* 居住地: Tokyo
* 生年月日: 1987/6/2

とある用途で使いたくて作ったものの、Qiita:Team周りのワークフローを自動化するときに便利そうだなと思ったのでrubygemにしました。

本年も宜しくお願い致します。

activeadmin読んだ

activeadmin/activeadminを初めて使うことになったので、どういう仕組みになっているのか調べてみた。

TL;DR

  • rails g active_admin:installを実行するとlib/generators/active_admin/install_generator.rbが実行され、ActiveAdmin.routes(self)がconfig/routes.rbに追加される。
  • app/admin/以下にあるResource定義ファイル内で実行されるActiveAdmin.registerでは、ActiveAdmin::Resourceインスタンスが生成され、動的にResourceごとのcontrollerが生成される。それらはすべてActiveAdmin::ResourceControllerを継承している。
  • config/routes.rbに追加されたActiveAdmin.routes(self)は内部でapp/admin/以下のファイルをロードし(このタイミングで上述のActiveAdmin.registerが実行される)、ActiveAdmin::Resourceインスタンスから動的にroutingが定義される。

長いので、以下ActiveAdmin::AA::と略記する。

Generator

Active Adminをセットアップするにはまずrails g active_admin:installを実行する。

このとき、lib/generators/active_admin/install_generator.rbに定義されたRails::Generators::Baseのサブクラスにあるpublicメソッドが上から順番に実行される。Railsはlib/generators/**/*_generator.rbにマッチするファイルに定義されたRails::Generators::BaseのサブクラスをRails Generatorとして実行することができる。

# lib/generators/active_admin/install_generator.rb

module ActiveAdmin
  module Generators
    class InstallGenerator < ActiveRecord::Generators::Base
      # ...

      def setup_routes
        if options[:user]
          inject_into_file "config/routes.rb", "\n  ActiveAdmin.routes(self)", after: /devise_for .*, ActiveAdmin::Devise\.config/
        else
          route "ActiveAdmin.routes(self)"
        end
      end

      # ...
    end
  end
end

いくつかメソッドが定義されている中でsetup_routesを見ると、config/routes.rbにActiveAdmin.route(self)を追記しているようだ。selfRails.application.routes.draw do ... endのブロック内でのselfなのでActionDispatch::Routing::Mapperインスタンスを表している。

Register a resource

Generatorでファイルの追加と変更を行ったあとは、管理画面で管理するResourceを作成する。例えば、rails g active_admin:resource Postを実行すると以下のようなapp/admin/post.rbが生成される。

# app/admin/post.rb

ActiveAdmin.register Post do
end

このブロックの中にviewやcontrollerの設定を追加していくのだけど、まずActiveAdmin.registerの定義を調べる。

# lib/active_admin.rb

module ActiveAdmin
  class << self
    # ...

    def application
      @application ||= ::ActiveAdmin::Application.new
    end

    # ...

    delegate :register, to: :application

    # ...
  end
end

ActiveSupportが拡張したメソッドdelegateによって、ActiveAdmin.registerの処理は実際にはAA::Application#registerが行っている。

# lib/active_admin/application.rb

def register(resource, options = {}, &block)
  ns = options.fetch(:namespace){ default_namespace }
  namespace(ns).register resource, options, &block
end

options[:namespace]がなければdefault_namespaceつまり:adminnsに入る。#namespacenamespaces[ns]があればそれを返し、なければAA::Namespaceインスタンスを初期化しnamespacesに追加した上で返す。よって、AA::Namespace#registerが処理が渡っている。

# lib/active_admin/namespace.rb

def register(resource_class, options = {}, &block)
  config = find_or_build_resource(resource_class, options)

  register_resource_controller(config)
  parse_registration_block(config, resource_class, &block) if block_given?
  reset_menu!

  ActiveAdmin::Event.dispatch ActiveAdmin::Resource::RegisterEvent, config

  config
end

find_or_build_resourceAA::Resourceインスタンスを返す。#register_resource_controllerは以下のように定義されており、Resourceインスタンスから動的にAA::ResourceControllerを継承するResourceごとのcontrollerを定義している。

# lib/active_admin/namespace.rb

def register_resource_controller
  eval "class ::#{config.controller_name} < ActiveAdmin::ResourceController; end"
  config.controller.active_admin_config = config
end

parse_registration_blockは上述の例のapp/admin/post.rbでActiveAdmin.registerに渡されていたブロックを評価する部分だと思う。ブロックの中身を独自のDSLとして評価してカスタマイズを行っていると思う。

Routing

Generatorによってconfig/routes.rbに追加されたActiveAdmin.routesの定義を調べる。

# lib/active_admin.rb

module ActiveAdmin
  # ...

  def application
    @application ||= ::ActiveAdmin::Application.new
  end

  # ...

  delegate :routes, to: :application

  # ...
end

delegateActiveSupportが拡張しているメソッドで、メソッドの呼び出しをtoで指定したオブジェクトに委譲する。なので、ActiveAdmin.routesは実際にはAA::Application#routesを指している。

# lib/active_admin/application.rb

def routes(rails_router)
  load!
  router.apply(rails_router)
end

load!はapp/admin/**/*.rbをKernel.loadする。このとき上述したapp/admin/post.rbのようなResource定義ファイルがロードされる。そして、ActiveAdmin.registerが実行され各Resourceのcontrollerが定義される。

routerRouterインスタンスなので、Router#applyを調べる。

# lib/active_admin/router.rb

def apply(router)
  define_root_routes router
  define_resource_routes router
end

まずdefine_root_routesは以下のように定義されている。

# lib/active_admin/router.rb

def define_root_routes(router)
  router.instance_exec @application.namespaces.values do |namespaces|
    namespaces.each do |namespace|
      if namespace.root?
        root namespace.root_to_options.merge(to: namespace.root_to)
      else
        namespace namespace.name do
          root namespace.root_to_options.merge(to: namespace.root_to)
        end  
      end
    end
  end
end

このrouterActionDispatch::Routing::Mapperであり、@application.namespaces.valuesAA::Namespaceインスタンスの配列だ。

ActiveAdmin.registerに特にoptionを指定しない場合、namespace.root?trueとなる。namespace.root_to_optionsnamespace.root_toがどこで定義されているのか不明。。。なんだけど、AA::Application内にこれらが定義されており、root_to_options{}root_to"dashboard#index"となっている。どのようにしてAA::Namespaceにそれらが定義されるのか不明ではあるが、おそらくこれらの値が使われるのだと思う。よって、結局このメソッドroot to: "dashboard#index"としているだけだ。

# lib/active_admin/router.rb

def define_resource_routes(router)
  router.instance_exec @application.namespaces, self do |namespaces, aa_router|
    resources = namespaces.value.flat_map{ |n| n.resources.values }
    resources.each do |config|
      routes = aa_router.resource_routes(config)

      # ...

      instance_exec &routes
    end
  end
end

configは先述したAA::Resourceインスタンスだ。aa_routerAA::RouterインスタンスなのでAA::Router#resource_routesを見る。

# lib/active_admin/router.rb

def resource_routes(config)
  Prox.new do
    build_route = proc{ |verbs, *args|
      [*verbs].each{ |verb| send verb, *args }
    }
    build_action = proc{ |action|
      build_route.call(action.http_verb, action.name)
    }
    case config
    when ::ActiveAdmin::Resource
      resources config.resource_name.route_key, only: config.defined_actions do
        member do
          config.member_actions.each &build_action
        end

        collection do
          config.collection_actions.each &build_action
          post :batch_action if config.batch_action_enabled?
        end
      end
    when ::ActiveAdmin::Page
      # ...
    else
      # ...
    end
  end
end

このメソッドで返されるProcオブジェクトはActionDispatch::Routing::Mapperインスタンスのコンテキストでinstance_execされるため、要するにこのProcオブジェクト内の処理がそのままconfig/routes.rb内のroutingの設定となる。Resourceインスタンスの情報から動的にroutingを組み立てているようだ。

所感

軽く触ってみたけど、Resource定義ファイルに独自DSLでviewを書いていくのが非常にカスタマイズが大変だし覚えることが多そうだな、あまり筋がよくなさそうという印象を受けた。

で、調べてみた結果、Resource定義ファイルから動的にcontrollerとかroutingとかを定義していて、それらをカスタマイズするのに独自DSLを使うという構図になっていることが分かった。管理画面って、ビジネスサイドの要求によってどんどんカスタマイズが必要になるので、カスタマイズに独自のDSLを覚えなくてはいけないとか、場合によってはカスタマイズできないみたいな状況は大きな問題だと思う。だから、動的にいろいろ生成する方針は管理画面の実装には適していないのではないかと思った。なんでこれがこんなに支持されているのかよくわからない。

RailsのReloaderの仕組み

Resqueのworker上で実行されるコードが古いまま更新されないというような問題があって、意味もわからず書いたコードでなんとかその場を収めたんだけど、気持ち悪いのでRailsでいかにしてコードが更新されるのか調べてみることにした。

TL;DR

未定義の定数が参照されると、ActiveSupport::ModuleConstMissingによって拡張されたModule#const_missingが呼ばれる。命名規則に基いて定数名からファイル名が推測され、autoload_pathsに存在するファイルを見つける。そのファイルがロード済みであればそこで終了。ロード済みでなければKernel.loadでロードし、ロードされた定数は配列で管理される。

development環境では、Railsのmiddleware stackにActionDispatch::Reloaderというmiddlewareがあり、リクエストごとにファイルの最終更新日時を確認し、変更されていればActiveSupport::Dependencies.clearを呼ぶ。これによって、ロード済みのファイルが空っぽになり、ロードされた定数もすべて削除される(=未定義状態に戻る)。なので、ファイルが変更されるたびにconst_missingから始まる一連のフローが起こり、Kernel.loadによって最新のコードがロードされるようになっている。

以下は、上述の結論に至るまでのソースコードリーディングのメモです。分かりにくいかも。

ActionDispatch::Reloader

ActionDispatch::ReloaderはRackミドルウェアなので、#callの中で初期化時に受け取った他のRackミドルウェア#callを呼んでいる。その前後でリロードに関する処理を実行しているはずだ。

def call(env)
  @validated = @condition.call
  prepare!

  response = @app.call(env)
  response[2] = ::Rack::BodyProxy.new(response[2]) { cleanup! }

  response
rescue Exception
  cleanup!
  raise
end

def prepare!
  run_callbacks :prepare if validated?
end

def cleanup!
  run_callbacks :cleanup if validated?
ensure
  @validated = true
end

次のRackミドルウェアが処理を行う前後でprepare!cleanup!を呼んでいる。その中身はrun_callbacksを呼んでいる。これはActiveSupport::Callbacksで定義されているメソッドで、set_callbacksで登録されたcallbackを実行する。なので、:prepare:cleanupというイベントに対してどこかで登録されたcallbackがされている。このcallbackの登録を行うメソッドActionDispatch::Reloaderに含まれている。

def self.to_prepare(*args, &block)
  # ...
  set_callbacks(:prepare, *args, &block)
end

def self.to_cleanup(*args, &block)
  # ...
  set_callbacks(:cleanup, *args, &block)
end

この2つのメソッドを使ってcallbackの登録が行われている。これらを呼び出している箇所を探すと、Rails::Application::Finisherで呼ばれていることがわかる。

Rails::Application::Finisher

initializer :set_clear_dependencies_hook, group: :all do
  callback = lambda do
    ActiveSupport::DescendantsTracker.clear
    ActiveSupport::Dependencies.clear
  end

  if config.reload_classes_only_on_change
    reloader = config.file_watcher.new(*watchable_args, &callback)
    self.reloaders << reloader

    ActionDispatch::Reloader.to_prepare(prepend: true) do
      reloader.execute
    end
  else
    ActionDispatch::Reloader.to_cleanup(&callback)
  end
end

initializerRails::Initializableで定義されているメソッドRailsの初期化時に実行される処理を登録することができる。つまり、Railsの初期化時に:prepareまたは:cleanupのcallbackを登録しているということになる。

reload_classes_only_on_changeという設定はデフォルトでtrueになっていて、依存するファイルが変更されたときだけクラスを再読み込みするかどうかを制御する。file_watcherはデフォルトではActiveSupport::FileUpdateCheckerを指している。つまり、デフォルトでは、:prepareのときにActiveSupport::FileUpdateChecker#executeが実行されるように設定されていることになる。

ActiveSupport::FileUpdateCheckerは初期化時に渡されたファイルを配列として受け取り、またそれらが更新されたときに実行されるブロックを受け取る。#executeはファイルが更新されているかどうかに関わらずブロックを実行する。ここで実行されるのは、以下のブロックとなる。

callback = lambda do
  ActiveSupport::DescendantsTracker.clear
  ActiveSupport::Dependencies.clear
end

ここまでをまとめると、リクエストを受けるごとに上の2つのメソッドが実行されコードのリロードが行われるということになる。

ActiveSupport::DescendantsTracker

def clear
  if defined? ActiveSupport::Dependencies
    @@direct_descendants.each do |klass, descendants|
      if ActiveSupport::Dependencies.autoloaded?(klass)
        @@direct_descendants.delete(klass)
      else
        descendants.reject! { |v| ActiveSupport::Dependencies.autoloaded?(v) }
      end
    end
  else
    @@direct_descendants.clear
  end
end

@@direct_descendantsの中身を消去しているようだ。これはHashであり、中身がキーがクラスで、値がそのクラスを継承したサブクラスの配列となっている。Class#inheritedをoverrideしており、ActiveSupport::DescendantsTrackerextendしているクラスを継承したタイミングで@@direct_descendantsに追加される。ActiveSupport::DescendantsTrackerは例えばActiveRecord::Baseextendされているため、ActiveRecord::Baseのサブクラス、つまり通常のModelクラスはActiveRecord::Base.descendantsから取得できる。これを利用しているのが先述したActiveSupport::Callbacksで、callbackをサブクラスから親クラスへ辿っていくときに利用されている。

ActiveSupport::Dependencies

def clear
  log_call
  loaded.clear
  loading.clear
  remove_unloadable_constants!
end

loadedloadingはクラス変数@@loadedおよび@@loadingへのアクセサでmattr_accessorによって定義されている。そして、これらのクラス変数の実体はSetオブジェクトだ。

次にloadedloadingにいつ何が追加されるのか調べると、ActiveSupport::Dependencies.require_or_loadというメソッドで呼ばれている。

def require_or_load(file_name, const_path = nil)
  # ...
  file_name = $` if file_name =~ /\.rb\z/
  expanded = File.expanded_path(file_name)
  return if loaded.include?(expanded)

  loaded << expanded
  loading << expanded

  begin
    if load?
      # ...

      load_args = ["#{file_name}.rb"]
      load_args << const_path unless const_path.nil?

      if !warnings_on_first_load or history.include?(expanded)
        result = load_file(*load_args)
      else
        enable_warnings { result = load_file(*load_args) }
      end      
    else
      # ...
    end
  rescue Exception
    loaded.delete expanded
    raise
  ensure
    loading.pop
  end

  history << expanded
  result
end

loadedloadingに追加されているのはおそらくロードするファイルの絶対パスと思われる。そして、一連のロードが完了したらloadingからは削除されるようだ。loadingは読み込み中の再読み込みを防ぐために一時的に利用される変数らしい。一方、loadedは既にロード済みかどうかをチェックして、ロード済みであればrequire_or_loadを中断させるために使われているようだ。実際のロードの処理はload_fileで行われるようだ。ActiveSupport::Dependencies.clearによってloadedが空になると、require_or_load内で再度load_fileを実行することになる。

def load_file(path, const_paths = loadable_constants_for_path(path))
  # ...
  const_paths = [const_paths].compact unless const_paths.is_a? Array
  parent_paths = const_paths.collect { |const_path| const_path[/.*(?=::)/] || ::Object }

  result = nil
  newly_defined_paths = new_constants_in(*parent_paths) do
    result = Kernel.load path
  end

  autoloaded_constants.concat newly_defined_paths unless load_once_path?(path)
  autoloaded_constants.uniq!
  # ...
  result
end

const_pathsapp/models/user.rbapp/controllers/users_controller.rbいうファイルがあれば"User""UsersController"という表す文字列を含む配列となる。parent_pathsconst_pathsの中で"Admin::UsersController"のようなネストするものと"::Object"を抽出した配列となる。new_constants_inは渡したブロックを実行し、その中で新たにロードされた定数を返す。なので、Kernel.loadがとりあえず実行されるようだ。

話を少し戻してrequire_or_loadはどこで呼ばれているかを調べると、load_missing_constantで呼ばれており、さらにこのメソッドActiveSupport::ModuleConstMissing#const_missingで呼ばれている。

module ModuleConstMissing
  # ...

  def const_missing(const_name)
    from_mod = anonymous ? guess_for_anonymous(const_name) : self
    Dependencies.load_missing_constant(from_mod, const_name)
  end
end

そしてこのmoduleは以下のようにしてModuleクラスにincludeされるため、デフォルトのconst_missingの挙動をoverrideすることになる。

module ActiveSupport
  module Dependencies
    # ...

    def hook!
      Object.class_eval { include Loadable }
      Module.class_eval { include ModuleConstMissing }
      Exception.class_eval { include Blamable }
    end

    # ...
  end
end

ActiveSupport::Dependencies.hook!

つまり、定義されていない定数を参照する→const_missingload_missing_constantrequire_or_loadload_fileという順番で呼ばれることになる。

ここで、ActiveSupport::Dependencies.clearの定義に戻る。

def clear
  log_call
  loaded.clear
  loading.clear
  remove_unloadable_constants!
end

見ていなかったremove_unloadable_constants!について見ていく。

def remove_unloadable_constants!
  autoloaded_constants.each { |const| remove_constant const }
  autoloaded_constants.clear
  Reference.clear!
  explicitly_unloadable_constants.each { |const| remove_constant const }
end

autoloaded_constantsは上述のload_fileでロードされた定数を含む配列だ。remove_constantはその名の通り定数を削除するメソッドで内部でModule#remove_constを呼んでいる。

Qiita API v2のSwiftクライアントを書き始めた

昨日、Qiita API v2 Hackathonに参加してきた。

僕はハッカソンでnaoty/qiita-swiftを作った。Qiita API v2のSwiftクライアントだ。動機としては、Swiftはすごく楽しいのだけどJSONのパースあたりがとてもつらいという問題があって、そこらへんの地味な実装を代わりに担うライブラリを書くことで、楽しいSwiftの開発をより楽しくできると思った。

使い方

let client = Qiita.Client(accessToken: "...")

let parameters = ["query": "user:naoty_k"]
client.getItems(parameters: parameters).onComplete({ items in
  // ...
}).onError({ error in
  // ...
}).resume()

Promiseっぽい感じで非同期処理をメソッドチェーンで記述することができる。対応しているAPIは、ハッカソンで数時間で作っただけだから、まだユーザーと投稿のGETリクエストのみしかないので、今後追加していきたい。

ハッカソンについての所見

これまで何回かハッカソンに参加してきたけど、ハッカソンで作ったソフトウェアの開発をその後も続けた、ということはなかった。そのことがハッカソンに参加するたびに心にひっかかっていた。というのも、ハッカソンで開発するソフトウェアはなにかしらの問題を解決するために作っているはずで、その場かぎりの開発で解決できることはめったにないと思うからだ。ハッカソンは解決する価値のある問題を発見し、その解決策のプロトタイピングを短時間で行うイベントだと思う。だから、ハッカソンはきっかけであって解決に値する問題であればその後も継続的に開発していくべきなんだと思う。だから、開発しようと思った動機を大切にして、開発を継続していこうと思う。

転職のお知らせ

12/1から新しい職場で働くことになりました。久々のチーム開発なので楽しみです。相変わらずRailsやったりiOSやったりな感じになりそうです。転職先は卓球ハウスに遊びに来られたときにでもお話します。

いつものです。よろしくお願いします。

Web API: The Good Partsを読んだ

Web API: The Good Parts

Web API: The Good Parts

業務ではiOSアプリとバックエンドの開発を両方担当しているので、APIの設計を何回かやってきた。しかし、自分なりのやり方でやってきた部分が多かったので、最近発売されたWeb API: The Good Partsを読んでちゃんとした設計について学ぶことにした。

得られた学びをメモとして残す。

HATEOAS

HATEOAS(Hypermedia As The Engine Of Application State)という設計方法を初めて知った。HATEOASではまず、サーバー側はレスポンスに関連するエンドポイントを含め次にアクセスするAPIを簡単に辿れるようにする。クライアント側は最初のエンドポイント以外はハードコーディングせずにレスポンスに含まれるエンドポイントだけにアクセスを制限する。このようにすることで、アプリケーションのワークフローをクライアント側で制御するのではなく、サーバーサイドで制御できるようになる。

HATEOASのメリットとしては、特にモバイルアプリケーション開発の文脈で重要なこととして、最初にアクセスするものを除いてエンドポイントをハードコーディングせずに済むということだと思う。一度リリースしてしまったアプリにハードコーディングされたエンドポイントは互換性を死守しなければならないが、レスポンス中に含まれるものを使うのであれば柔軟にエンドポイントを変更することが可能になる。

例として、配列を返すレスポンスに前後のページへのリンクを含める場合はこのようになる。

{
  friends: [
    {
      id: 12345,
      name: "Alice"
    },
    {
      id: 12346,
      name: "Bob"
    }
  ],
  hasNext: true,
  links: [
    {
      uri: "http://api.example.com/v1/users?page=3&per_page=20",
      rel: "next"
    },
    {
      uri: "http://api.example.com/v1/users?page=1&per_page=20",
      rel: "prev"
    }
  }
}

エラー時のレスポンス

エラーが発生した場合は適切なHTTPステータスコードを返すのは当然としてエラーの詳細情報を返す必要がある。それらはレスポンスボディに含めることが多い。複数のエラーが同時に発生する場合を考慮して、詳細情報を配列で返す方が親切だと思う。詳細情報には、エラーメッセージだけでなく、API提供側で独自に定義した詳細コードが含まれることがある。詳細コードはステータスコードと混同しないように4桁にして、1000番台は汎用的なエラー、2000番台はユーザー情報に関連するエラーというようにカテゴリー分けすると便利。

{
  errors: [
    {
      message: "Not Found",
      code: 1013
    }
  ]
}

クライアント側の実装としては、HTTPステータスコードではなく詳細コードごとにエラーメッセージを出し分ける方がユーザーにとってフレンドリーなUIになるだろう。

キャッシュ

HTTPのキャッシュの仕様には2種類あり、期限切れモデルと検証モデルがある。

期限切れモデルでは、キャッシュの保存期限をサーバー側で指定し、クライアントはその期限中は通信を行わなくなり期限が切れたらアクセスを再開する。期限の指定方法はCache-ControlヘッダーとExpiresヘッダーの2種類ある。Cache-ControlDateヘッダーの日時からの経過時間を指定する。Expiresは期限を表す日時を指定する。Cache-Controlは頻繁には更新されないデータに使われ、Expiresは天気情報など決まった時間に更新されることがわかっている場合に使われることが多い。

検証モデルでは、クライアントは今持っているキャッシュが有効かどうかを問い合わせて無効だったときだけ取得する。サーバー側はデータを返す際Last-ModifiedヘッダーとETagヘッダーを返し、クライアントに保持してもらう。そして、クライアントが問い合わせるときに送られたIf-Modified-SinceIf-None-Matchと比較して変化しているかどうかを判定する。変化していればキャッシュは無効とみなし再取得させる。期限切れモデルとは異なり、毎回アクセスは発生しているため、データ自体がそこまで大きくないとあまり効果はない。

使い分けとしては、頻繁に更新されないものや定期的に更新されるようなデータは期限切れモデルを使い、頻繁に更新される可能性があるものは検証モデルを利用するのがよさそうだ。

その他

OAuth2.0のフロー、各HTTPステータスコードの説明、APIにまつわるセキュリティなどAPIを設計する上で必要となる周辺知識がのっている。個人的に気になっていたAPIオーケストレーションの話はそこまで取り上げられていなかった。

MDCSwipeToChooseを読んだ

前回に引き続き、UI周りのテクニックを学ぶためhttps://github.com/modocache/MDCSwipeToChooseを読む。海外で話題のTinder風のアプリを簡単に開発することができる。

まず使い方を簡単に見ていく。

override func viewDidLoad() {
    let options = MDCSwipeToChooseViewOptions()
    options.delegate = self
    options.likedText = "Like"
    options.likedColor = UIColor.blueColor()
    options.nopeText = "Nope"
    options.nopeColor = UIColor.redColor()
    options.onPan = { state in NSLog("Panning") }

    let swipableView = MDCSwipeToChooseView(frame: view.frame, options: options)
    view.addSubview(swipableview)
}

func viewDidCancelSwipe(view: UIView!) {
    NSLog("Cancel to choose")
}

func view(view: UIView!, wasChoosenWithDirection direction: MDCSwipeDirection) }
    NSLog("Choose to \(direction == .Left ? "Left" : "Right")")
}
  • MDCSwipeToChooseViewOptionsオブジェクトにスワイプするViewの設定をまとめて初期化時に渡している。
    • delegateMDCSwipeToChooseDelegateを実装するオブジェクトである必要がある。
    • likedTextとかnopeTextというのは、右もしくは左にスワイプされるときにView上に表示されるテキストのこと。
    • onPanはスワイプされているときに呼ばれる処理。
  • MDCSwipeToChooseDelegateメソッドとしてviewDidCancelSwipe()view(view:wasChoosenWithDirection:)がある。前者はスワイプを途中でやめたとき、後者はスワイプしてViewをどちらかに選んだときに呼ばれる。

さらに、MDCSwipeToChooseViewだけではなく、UIViewをスワイプできるようにするカテゴリも用意されているため、より柔軟に実装できるようになっている。

今回、重点的に読んでいきたいのは以下のポイントだ。

  • ライブラリの設計。カテゴリも含めた柔軟な実装を可能にするのは優れた設計があるからだと思うので参考にしたい。
  • スワイプに合わせたViewの動き。

ライブラリの設計

このライブラリの作者の書いたiOS UI Component API Designという記事によると、設計において2点考慮されているようだ。

  1. 継承よりカテゴリーによるコンポジションを選ぶ。
  2. デリゲートメソッドやブロックの引数にパラメータオブジェクトを使う。

継承よりカテゴリーによるコンポジション

MDCSwipeToChooseViewに機能を追加したい場合、サブクラスを定義する必要がある。しかし、この方法では別のライブラリが提供するViewのもつ機能を組み込むことができない。そこで、カテゴリーでUIViewに機能を拡張することで、他のライブラリとも組み合わせることができる。

カテゴリーによる拡張の欠点はインスタンス変数を追加することができないことだ。そのため、プロパティをカテゴリーによって拡張する場合は、<objc/runtime.h>objc_setAssociatedObject()を使ったトリッキーな実装が必要になる。

より簡単に実装するには、カスタマイズ用のパラメータを束ねる設定オブジェクトを使うのがよさそう。この設定オブジェクトのプロパティだけは上記のトリッキーな手法で拡張するしかないが、Viewをカスタマイズする変数はすべてのこの設定オブジェクトに隠ぺいする。このライブラリでの設定オブジェクトはMDCSwipeOptionsMDCSwipeToChooseViewOptionsだった。

パラメータオブジェクト

デリゲートメソッドonPanなどのブロックのシグネチャがバージョンアップデートで変更されてしまうと互換性がなくなってしまう。そこで、複数の引数をまとめたパラメータオブジェクトというのを用意し、引数の変更をすべてパラメータオブジェクト内の変更で吸収することで、メソッドシグネチャを変更せずに互換性を保つことができる。このライブラリではMDCPanStateがパラメータオブジェクトの役割を果たしている。

typedef void (^MDCSwipeToChooseOnPanBlock)(MDCPanState *state);

@interface MDCSwipeOptions : NSObject

// ...

@property (nonatomic, copy) MDCSwipeToChooseOnPanBlock onPan;

// ...

@end
@interface MDCPanState : NSObject

@property (nonatomic, strong) UIView *view;
@property (nonatomic, assign) MDCSwipeDirection direction;
@property (nonatomic, assign) CGFloat thresholdRatio;

@end

初期化から表示まで

設計について確認したので、初期化から表示されるまでの流れからソースコードを読んでいく。

MDCSwipeToChooseView.m:44

- (instancetype)initWithFrame:(CGRect)frame options:(MDCSwipeToChooseViewOptions *)options {
    self = [super initWithFrame:frame];
    if (self) {
        _options = options ? options : [MDCSwipeToChooseViewOptions new];
        [self setupView];
        [self constructImageView];
        [self constructLikedView];
        [self constructNopeImageView];
        [self setupSwipeToChoose];
    }
    return self;
}
  • まずこのクラスのスーパークラスUIViewなので、普通の初期化処理をしたあとにセットアップ処理を実行している。
  • いくつかのセットアップ処理を順に見ていく。

MDCSwipeToChooseView.m:59

- (void)setupView {
    self.backgroundColor = [UIColor clearColor];
    self.layer.cornerRadius = 5.f;
    self.layer.borderWidth = 2.f;
    self.layer.borderColor = [UIColor colorWith8BitRed:220.f
                                                 green:220.f
                                                  blue:220.f
                                                 alpha:1.f].CGColor;
}
  • selfの見た目に関する設定をしている。
  • 背景色が透明で、角丸で、枠線の太さと色を指定しているだけのようだ。

MDCSwipeToChooseView.m:69

- (void)constructImageView {
    _imageView = [[UIImageView alloc] initWithFrame:self.bounds];
    _imageView.clipsToBounds = YES;
    [self addSubview:_imageView];
}
  • selfと同じ大きさの_imageViewを作ってサブビューに追加している。
  • -[UIView clipsToBounds]はサブビューを自分のboundsのサイズで切り抜く設定だ。YESを指定すると、サブビューの自分からはみ出た部分は表示されなくなる。

MDCSwipeToChooseView.m:75

- (void)constructLikedView {
    CGRect frame = CGRectMake(MDCSwipeToChooseViewHorizontalPadding,
                              MDCSwipeToChooseViewTopPadding,
                              CGRectGetMidX(_imageView.bounds),
                              MDCSwipeToChooseViewLabelWidth);
    self.likedView = [[UIView alloc] initWithFrame:frame];
    [self.likedView constructBorderedLabelWithText:self.options.likedText
                                             color:self.options.likedColor
                                             angle:self.options.likedRotationAngle];
    self.likedView.alpha = 0.f;
    [self.imageView addSubview:self.likedView];
}
  • likedViewというのは右にスワイプしたときに浮かび上がるテキストのためのビュー。
  • 最初は非表示になっているのでalpha0になっている。
  • -[UIView constructBorderedLabelWithText:color:angle]というメソッドUIView+MDCBorderedLabel.mで定義されている拡張。

UIView+MDCBorderedLabel.m:31

- (void)constructBorderedLabelWithText:(NSString *)text
                                 color:(UIColor *)color
                                 angle:(CGFloat)angle {
    self.layer.borderColor = color.CGColor;
    self.layer.borderWidth = 5.f;
    self.layer.cornerRadius = 10.f;

    UILabel *label = [[UILabel alloc] initWithFrame:self.bounds];
    label.text = [text uppercaseString];
    label.textAlignment = NSTextAlignmentCenter;
    label.font = [UIFont fontWithName:@"HelveticaNeue-CondensedBlack"
                                 size:48.f];
    label.textColor = color;
    [self addSubview:label];

    self.transform = CGAffineTransformRotate(CGAffineTransformIdentity,
                                             MDCDegreesToRadians(angle));
}
  • 角丸や枠線の設定をして、UILabelをサブビューに追加している。
  • -[UIView transform]centerまたはanchorPointを基準とした変換値を表す。これを設定するとその変換が適用される。CGAffineTransformRotate()は回転のためのアフィン変換行列を返す。第1引数に既存のアフィン変換、第2引数に回転角度を指定する。

MDCSwipeToChooseView.m:88

- (void)constructNopeImageView {
    CGFloat width = CGRectGetMidX(self.imageView.bounds);
    CGFloat xOrigin = CGRectGetMaxX(_imageView.bounds) - width - MDCSwipeToChooseViewHorizontalPadding;
    self.nopeView = [[UIImageView alloc] initWithFrame:CGRectMake(xOrigin,
                                                                  MDCSwipeToChooseViewTopPadding,
                                                                  width,
                                                                  MDCSwipeToChooseViewLabelWidth)];
    [self.nopeView constructBorderedLabelWithText:self.options.nopeText
                                            color:self.options.nopeColor
                                            angle:self.options.nopeRotationAngle];
    self.nopeView.alpha = 0.f;
    [self.imageView addSubview:self.nopeView];
}
  • こちらは左にスワイプしたときに浮かび上がるテキストのビュー。
  • _likedViewと大差ない。

MDCSwipeToChooseView.m:102

- (void)setupSwipeToChoose {
    MDCSwipeOptions *options = [MDCSwipeOptions new];
    options.delegate = self.options.delegate;
    options.threshold = self.options.threshold;

    __block UIView *likedImageView = self.likedView;
    __block UIView *nopeImageView = self.nopeView;
    __weak MDCSwipeToChooseView *weakself = self;
    options.onPan = ^(MDCPanState *state) {
        if (state.direction == MDCSwipeDirectionNone) {
            likedImageView.alpha = 0.f;
            nopeImageView.alpha = 0.f;
        } else if (state.direction == MDCSwipeDirectionLeft) {
            likedImageView.alpha = 0.f;
            nopeImageView.alpha = state.thresholdRatio;
        } else if (state.direction == MDCSwipeDirectionRight) {
            likedImageView.alpha = state.thresholdRatio;
            nopeImageView.alpha = 0.f;
        }

        if (weakself.options.onPan) {
            weakself.options.onPan(state);
        }
    };

    [self mdc_swipeToChooseSetup:options];
}
  • MDCSwipeOptionsオブジェクトを生成して、初期化時に渡されたself.optionsのプロパティをコピーしている。onPanブロック内で最後に初期化時に渡されたself.options.onPanが実行されるようになっている。
  • __block属性はブロック内で変更する場合に変数につける必要がある。
  • onPan内の処理を詳しく見ていく。
    • ブロックの引数に渡されるstatedirectionプロパティはMDCSwipeDirection型の値で、None, Left, Rightのいずれかだ。Leftなら`nopeImageViewのアルファ値を変更し表示されるようにしている。逆にRightならlikedImageViewを同様にして表示されるようにしている。
    • statethresholdRatioプロパティはコメントによると、ある閾値にどれだけ近づいているかを表す、0から1までの値だ。1のとき閾値に達したことを意味する。よって、ある閾値に達したときthresholdRatio1になり、likedImageViewまたはnopeImageViewのアルファ値が1になって完全に表示されるようになる。
  • -[UIView mdc_swipeToChooseSetup:]UIView+MDCSwipeToChoose.mで定義されたカテゴリーによって拡張されたメソッドだ。このライブラリはMDCSwipeToChooseViewという専用のクラスだけでなく、UIViewのカテゴリーを提供することでより柔軟に実装できるようになっているが、その中心部分はこのカテゴリー内で実装しているようだ。

UIView+MDCSwipeToChoose.m:38

- (void)mdc_swipeToChooseSetup:(MDCSwipeOptions *)options {
    self.mdc_options = options ? options : [MDCSwipeOptions new];
    self.mdc_viewState = [MDCViewState new];
    self.mdc_viewState.originalCenter = self.center;

    [self mdc_setupPanGestureRecognizer];
}
  • self.mdc_optionsself.mdc_viewStateを初期化している。
  • -[UIView mdc_setupPanGestureRecognizer]でジェスチャーのイベントハンドリングを実装しているのだろう。

スワイプに合わせたViewの動き

これまでMDCSwipeToChooseViewおよびUIView+MDCSwipeToChooseによる拡張部分の初期化について見てきた。これからスワイプに合わせてViewをどのように動かしているのかについて詳細に見ていく。

UIView+MDCSwipeToChoose.m:104

- (void)mdc_setupPanGestureRecognizer {
    SEL action = @selector(mdc_onSwipeToChoosePanGestureRecognizer:);
    UIPanGestureRecognizer *panGestureRecognizer =
    [[UIPanGestureRecognizer alloc] initWithTarget:self
                                            action:action];
    [self addGestureRecognizer:panGestureRecognizer];
}
  • UIPanGestureRecognizerを初期化してUIViewに追加している。パンというジェスチャーはスワイプとかドラッグのことだ。
  • スワイプされると-[UIView mdc_onSwipeToChoosePanGestureRecognizer:]が呼ばれるようだ。

UIView+MDCSwipeToChoose.m:227

- (void)mdc_onSwipeToChoosePanGestureRecognizer:(UIPanGestureRecognizer *)panGestureRecognizer {
    UIView *view = panGestureRecognizer.view;

    if (panGestureRecognizer.state == UIGestureRecognizerStateBegan) {
        self.mdc_viewState.originalCenter = view.center;

        // If the pan gesture originated at the top half of the view, rotate the view
        // away from the center. Otherwise, rotate towards the center.
        if ([panGestureRecognizer locationInView:view].y < view.center.y) {
            self.mdc_viewState.rotationDirection = MDCRotationAwayFromCenter;
        } else {
            self.mdc_viewState.rotationDirection = MDCRotationTowardsCenter;
        }
    } else if (panGestureRecognizer.state == UIGestureRecognizerStateEnded) {
        // Either move the view back to its original position or move it off screen.
        [self mdc_finalizePosition];
    } else {
        // Update the position and transform. Then, notify any listeners of
        // the updates via the pan block.
        CGPoint translation = [panGestureRecognizer translationInView:view];
        view.center = MDCCGPointAdd(self.mdc_viewState.originalCenter, translation);
        [self mdc_rotateForTranslation:translation
                     rotationDirection:self.mdc_viewState.rotationDirection];
        [self mdc_executeOnPanBlockForTranslation:translation];
    }
}
  • スワイプが始まったとき、ユーザーの指の位置がViewの上半分ならMDCRotationAwayFromCenterすなわち1.0、下半分ならMDCRotationTowardsCenterすなわち-1.0self.mdc_viewState.rotationDirectionにセットしている。
  • スワイプが終わったとき、-[UIVIew mdc_finalizePosition]を呼ぶ。ここはあとで詳細に見ることにしてスキップする。
  • スワイプ中
    • -[UIPanGestureRecognizer translationInView:]によって最初に指が触れた点からの移動量を取得している。
    • 取得した移動量をoriginalCenterに加えた値をcenterとすることで、ユーザーの指の位置が常にcenterになるようにViewを移動させているようだ。
    • -[UIView mdc_rotateForTranslation:rotationDirection:]によってViewを回転させているようだ。あとで詳細を見ることにする。
    • -[UIView mdc_executeOnPanBlockForTranslation:]はスワイプの状態からthresholdRatioを計算したりMDCPanStateを生成したりしてself.mdc_options.onPan()の引数に渡して実行している。ここで、Viewの初期化時に指定したonPanのブロックが実行されることになる。

UIView+MDCSwipeToChoose.m:189

後回しにしていた-[UIView mdc_rotateForTranslation:rotationDirection:]を先に見る。

- (void)mdc_rotateForTranslation:(CGPoint)translation
               rotationDirection:(MDCRotationDirection)rotationDirection {
    CGFloat rotation = MDCDegreesToRadians(translation.x/100 * self.mdc_options.rotationFactor);
    self.transform = CGAffineTransformRotate(CGAffineTransformIdentity,
                                             rotationDirection * rotation);
}
  • x軸方向への移動量 / 100に定数倍したものをラジアンに変換して、rotationDirection1.0or-1.0)を掛けた量を回転させている。

UIView+MDCSwipeToChoose.m:114

次に、スワイプが終了したときに呼ばれる-[UIView mdc_finalizePosition]を見ていく。

- (void)mdc_finalizePosition {
    MDCSwipeDirection direction = [self mdc_directionOfExceededThreshold];
    switch (direction) {
        case MDCSwipeDirectionRight:
        case MDCSwipeDirectionLeft: {
            CGPoint translation = MDCCGPointSubtract(self.center,
                                                     self.mdc_viewState.originalCenter);
            [self mdc_exitSuperviewFromTranslation:translation];
            break;
        }
        case MDCSwipeDirectionNone:
            [self mdc_returnToOriginalCenter];
            [self mdc_executeOnPanBlockForTranslation:CGPointZero];
            break;
    }
}
  • -[UIView mdc_directionOfExceededThreshold]である閾値を超えた方向を取得しているようだ。
  • 取得した方向が左か右であれば-[UIView mdc_exitSuperviewFromTranslation:]を呼び、どちらでもなかった場合は-[UIView mdc_returnToOriginalCenter]-[UIView mdc_executeOnPanBlockForTranslation:]を呼んでいる。

UIView+MDCSwipeToChoose.m:215

まず閾値をを超えた方向を取得する部分から見ていく。

- (MDCSwipeDirection)mdc_directionOfExceededThreshold {
    if (self.center.x > self.mdc_viewState.originalCenter.x + self.mdc_options.threshold) {
        return MDCSwipeDirectionRight;
    } else if (self.center.x < self.mdc_viewState.originalCenter.x - self.mdc_options.threshold) {
        return MDCSwipeDirectionLeft;
    } else {
        return MDCSwipeDirectionNone;
    }
}
  • どうやら閾値というのはself.mdc_options.thresholdのことのようだ。デフォルトでは100.0だ。
  • Viewの中心点のx座標がもともとの中心点のx座標から閾値以上移動した場合、右方向ならRight、左方向ならLeftを返している。そうでなければNoneを返している。

UIView+MDCSwipeToChoose.m:146

次に、上記の閾値を超えてどちらかの方向が返ってきた場合に呼ばれる-[UIView mdc_exitSuperviewFromTranslation:]を見る。

- (void)mdc_exitSuperviewFromTranslation:(CGPoint)translation {
    MDCSwipeDirection direction = [self mdc_directionOfExceededThreshold];
    id<MDCSwipeToChooseDelegate> delegate = self.mdc_options.delegate;
    if ([delegate respondsToSelector:@selector(view:shouldBeChosenWithDirection:)]) {
        BOOL should = [delegate view:self shouldBeChosenWithDirection:direction];
        if (!should) {
            return;
        }
    }

    MDCSwipeResult *state = [MDCSwipeResult new];
    state.view = self;
    state.translation = translation;
    state.direction = direction;
    state.onCompletion = ^{
        if ([delegate respondsToSelector:@selector(view:wasChosenWithDirection:)]) {
            [delegate view:self wasChosenWithDirection:direction];
        }
    };
    self.mdc_options.onChosen(state);
}
  • delegateview:shouldBeChosenWithDirection:が実装されていれば、それを呼びNOが返ってきた場合そこで終了する。
  • MDCSwipeResultオブジェクトを初期化してself.mdc_options.onChosen()に渡して実行している。

MDCSwipeOptions.m:33

onChosenは何を参照しているのか確認する。

- (instancetype)init {
    self = [super init];
    if (self) {
        _swipeCancelledAnimationDuration = 0.2;
        _swipeCancelledAnimationOptions = UIViewAnimationOptionCurveEaseOut;
        _swipeAnimationDuration = 0.1;
        _swipeAnimationOptions = UIViewAnimationOptionCurveEaseIn;
        _rotationFactor = 3.f;

        _onChosen = [[self class] exitScreenOnChosenWithDuration:0.1
                                                         options:UIViewAnimationOptionCurveLinear];
    }
    return self;
}
  • _onChosen+[MDCSwipeOptions exitScreenOnChosenWithDuration:options]の返り値を参照している。

MDCSwipeOptions.m:50

+ (MDCSwipeToChooseOnChosenBlock)exitScreenOnChosenWithDuration:(NSTimeInterval)duration
                                                        options:(UIViewAnimationOptions)options {
    return ^(MDCSwipeResult *state) {
        CGRect destination = MDCCGRectExtendedOutOfBounds(state.view.frame,
                                                          state.view.superview.bounds,
                                                          state.translation);
        [UIView animateWithDuration:duration
                              delay:0.0
                            options:options
                         animations:^{
                             state.view.frame = destination;
                         } completion:^(BOOL finished) {
                             if (finished) {
                                 [state.view removeFromSuperview];
                                 state.onCompletion();
                             }
                         }];
    };
}
  • このメソッドはブロックを返しているのであって、ブロックを実行しているわけではない。
  • その内容としては、Viewをスーパービューの外にアニメーションつきで移動させ、完了後にそのViewをスーパービューから削除し、state.onCompletion()を実行するというものだ。

UIView+MDCSwipeToChoose.m:146

いったん-[UIView mdc_exitSuperviewFromTranslation:]に戻ってonCompletionを確認する。

- (void)mdc_exitSuperviewFromTranslation:(CGPoint)translation {
    // ...

    MDCSwipeResult *state = [MDCSwipeResult new];
    state.view = self;
    state.translation = translation;
    state.direction = direction;
    state.onCompletion = ^{
        if ([delegate respondsToSelector:@selector(view:wasChosenWithDirection:)]) {
            [delegate view:self wasChosenWithDirection:direction];
        }
    };
    self.mdc_options.onChosen(state);
}
  • Viewが枠外に消えた後に、onChosen()の引数に渡されたstateonCompletionが実行されるので、ここではdelegateview:wasChosenWithDirection:が呼ばれることになる。

UIView+MDCSwipeToChoose.m:131

続いて、-[UIView mdc_finalizePosition]閾値を超えなかった場合に呼ばれる2つのメソッドのうち、-[UIView mdc_returnToOriginalCenter]を見る。

- (void)mdc_returnToOriginalCenter {
    [UIView animateWithDuration:self.mdc_options.swipeCancelledAnimationDuration
                          delay:0.0
                        options:self.mdc_options.swipeCancelledAnimationOptions
                     animations:^{
                         self.transform = CGAffineTransformIdentity;
                         self.center = self.mdc_viewState.originalCenter;
                     } completion:^(BOOL finished) {
                         id<MDCSwipeToChooseDelegate> delegate = self.mdc_options.delegate;
                         if ([delegate respondsToSelector:@selector(viewDidCancelSwipe:)]) {
                             [delegate viewDidCancelSwipe:self];
                         }
                     }];
}
  • アニメーションつきで回転を打ち消し、もともとの中心点に移動させている。
  • それらが完了したあと、delegateviewDidCancelSwipe:を呼んでいる。

UIView+MDCSwipeToChoose.m:168

もう1つの-[UIView mdc_executeOnPanBlockForTranslation:]を見る。

- (void)mdc_executeOnPanBlockForTranslation:(CGPoint)translation {
    if (self.mdc_options.onPan) {
        CGFloat thresholdRatio = MIN(1.f, fabsf(translation.x)/self.mdc_options.threshold);

        MDCSwipeDirection direction = MDCSwipeDirectionNone;
        if (translation.x > 0.f) {
            direction = MDCSwipeDirectionRight;
        } else if (translation.x < 0.f) {
            direction = MDCSwipeDirectionLeft;
        }

        MDCPanState *state = [MDCPanState new];
        state.view = self;
        state.direction = direction;
        state.thresholdRatio = thresholdRatio;
        self.mdc_options.onPan(state);
    }
}
  • 中心点に戻る際のonPanブロックを実行している。そのために、thresholdRatioを計算しMDCPanStateを初期化している。