Web API: The Good Partsを読んだ
- 作者: 水野貴明
- 出版社/メーカー: オライリージャパン
- 発売日: 2014/11/21
- メディア: 大型本
- この商品を含むブログ (1件) を見る
業務では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-Control
はDate
ヘッダーの日時からの経過時間を指定する。Expires
は期限を表す日時を指定する。Cache-Control
は頻繁には更新されないデータに使われ、Expires
は天気情報など決まった時間に更新されることがわかっている場合に使われることが多い。
検証モデルでは、クライアントは今持っているキャッシュが有効かどうかを問い合わせて無効だったときだけ取得する。サーバー側はデータを返す際Last-Modified
ヘッダーとETag
ヘッダーを返し、クライアントに保持してもらう。そして、クライアントが問い合わせるときに送られたIf-Modified-Since
やIf-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の設定をまとめて初期化時に渡している。delegate
はMDCSwipeToChooseDelegate
を実装するオブジェクトである必要がある。likedText
とかnopeText
というのは、右もしくは左にスワイプされるときにView上に表示されるテキストのこと。onPan
はスワイプされているときに呼ばれる処理。
MDCSwipeToChooseDelegate
のメソッドとしてviewDidCancelSwipe()
とview(view:wasChoosenWithDirection:)
がある。前者はスワイプを途中でやめたとき、後者はスワイプしてViewをどちらかに選んだときに呼ばれる。
さらに、MDCSwipeToChooseView
だけではなく、UIView
をスワイプできるようにするカテゴリも用意されているため、より柔軟に実装できるようになっている。
今回、重点的に読んでいきたいのは以下のポイントだ。
- ライブラリの設計。カテゴリも含めた柔軟な実装を可能にするのは優れた設計があるからだと思うので参考にしたい。
- スワイプに合わせたViewの動き。
ライブラリの設計
このライブラリの作者の書いたiOS UI Component API Designという記事によると、設計において2点考慮されているようだ。
継承よりカテゴリーによるコンポジション
MDCSwipeToChooseView
に機能を追加したい場合、サブクラスを定義する必要がある。しかし、この方法では別のライブラリが提供するViewのもつ機能を組み込むことができない。そこで、カテゴリーでUIView
に機能を拡張することで、他のライブラリとも組み合わせることができる。
カテゴリーによる拡張の欠点はインスタンス変数を追加することができないことだ。そのため、プロパティをカテゴリーによって拡張する場合は、<objc/runtime.h>
のobjc_setAssociatedObject()
を使ったトリッキーな実装が必要になる。
より簡単に実装するには、カスタマイズ用のパラメータを束ねる設定オブジェクトを使うのがよさそう。この設定オブジェクトのプロパティだけは上記のトリッキーな手法で拡張するしかないが、Viewをカスタマイズする変数はすべてのこの設定オブジェクトに隠ぺいする。このライブラリでの設定オブジェクトはMDCSwipeOptions
とMDCSwipeToChooseViewOptions
だった。
パラメータオブジェクト
デリゲートメソッドや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
というのは右にスワイプしたときに浮かび上がるテキストのためのビュー。- 最初は非表示になっているので
alpha
が0
になっている。 -[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
内の処理を詳しく見ていく。- ブロックの引数に渡される
state
のdirection
プロパティはMDCSwipeDirection
型の値で、None
,Left
,Right
のいずれかだ。Left
なら`nopeImageView
のアルファ値を変更し表示されるようにしている。逆にRight
ならlikedImageView
を同様にして表示されるようにしている。 state
のthresholdRatio
プロパティはコメントによると、ある閾値にどれだけ近づいているかを表す、0
から1
までの値だ。1
のとき閾値に達したことを意味する。よって、ある閾値に達したときthresholdRatio
が1
になり、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_options
とself.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.0
をself.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
に定数倍したものをラジアンに変換して、rotationDirection
(1.0
or-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); }
delegate
にview: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()
の引数に渡されたstate
のonCompletion
が実行されるので、ここではdelegate
のview: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]; } }]; }
- アニメーションつきで回転を打ち消し、もともとの中心点に移動させている。
- それらが完了したあと、
delegate
のviewDidCancelSwipe:
を呼んでいる。
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
を初期化している。
SlackTextViewControllerを読んだ
UI周りの理解を深めるため、Slackが公開しているhttps://github.com/slackhq/SlackTextViewControllerを読む。コミット番号は9fcf06ac6f7004e4aacb6536b375d1cb03f08289
だ。
全部はさすがに読みきれないので、以下の気になるポイントに集中してコードを読んでいくことにする。
- キーボードの表示/非表示に伴うレイアウトの調整。何も工夫しないとキーボードでViewが隠れてしまうはずだ。
- ユーザー名や絵文字の補完
TL;DR
- キーボードの表示/非表示の際に送信される通知
UIKeyboardWillShowNotification
等を使ってレイアウトを調整している。レイアウトはすべてAuto Layout上の制約をプログラムで制御することで調整している。例えば、キーボードの高さに併せてスクロールビューの高さを大きくしたり小さくしたりしている。 UITextView
上のカーソル位置が変更されるタイミングで、事前に登録されたプレフィックスにマッチするかどうかチェックしている。マッチすれば、補完候補を表示するUITextView
を表示し、それに併せて各Viewのレイアウトを調整している。- 全体を通して「キーボード等の状態が変化する」→「各Viewの適切な高さを計算する」→「
-[NSLayoutConstraint constant]
を更新する」→「-[CALayer layoutIfNeeded]
を呼んで再描画する」という流れだった。
初期化から表示まで
とりあえず、初期化から表示までの流れを先に抑えておく。
SlackTextViewController.m:115
- (instancetype)initWithCoder:(NSCoder *)decoder { NSAssert([self class] != [SLKTextViewController class], @"Oops! You must subclass SLKTextViewController."); if (self = [super initWithCoder:decoder]) { UITableViewStyle tableViewStyle = [[self class] tableViewStyleForCoder:decoder]; UICollectionViewLayout *collectionViewLayout = [[self class] collectionViewLayoutForCoder:decoder]; if ([collectionViewLayout isKindOfClass:[UICollectionViewLayout class]]) { [self collectionViewWithLayout:collectionViewLayout]; } else if (tableViewStyle == UITableViewStylePlain || tableViewStyle == UITableViewStyleGrouped) { [self tableViewWithStyle:tableViewStyle]; } else { return nil; } [self commonInit]; } return self; }
NSAssert()
は第一引数がtrue
であることを表明するために使われる。false
ならそこで第二引数のメッセージをログに出力して強制終了する。-[tableViewWithStyle:]
がやっていることは主に2つ。_tableView
の初期化。_scrollViewProxy
の初期化。これは実際には_tableView
を参照している。また、タップしたらキーボードを閉じる設定をしている。
-[commonInit]
は名前の通り、他の初期化メソッド内でも呼ばれており、主に以下のようなことを行っている。- 各状態プロパティの初期化。
- 多数のオブザーバーを登録する。
SlackTextViewController.m:160
次に、ViewControllerがself.view
を初期化する際に呼ばれるloadView
を読む。
- (void)loadView { [super loadView]; [self.view addSubview:self.scrollViewProxy]; [self.view addSubview:self.autoCompletionView]; [self.view addSubview:self.typingIndicatorView]; [self.view addSubview:self.textInputbar]; }
self.view
を初期化したあと、self.scrollViewProxy
,self.autoCompletionView
,self.typingIndicatorView
,self.textInputbar
の4つのサブビューが追加されている。それぞれのサブビューは以下のようなものだ。self.scrollViewProxy
: 上でも見たようにself.tableView
のこと。self.autoCompletionView
: おそらく補完候補を表示するUITableView
だと思われる。self.typingIndicatorView
: 「◯◯が入力中…」のようなメッセージを表示するためのViewで、SLKTextIndicatorView
というカスタムViewとして定義されている。self.textInputbar
: テキストの入力フォームや送信ボタンを含むUIToolBar
で、これもSLKTextInputbar
というカスタムViewとして定義されている。
SlackTextViewController.m:165
- (void)viewDidLoad { [super viewDidLoad]; [UIView performWithoutAnimation:^{ [self reloadTextView]; [self setupViewConstraints]; }]; }
-[reloadTextView]
はキャッシュされた入力テキストを復旧してTextFieldに表示している。-[SlackTextViewController setupViewConstraints]
は全体を通して重要なメソッドなので、詳細に見ていく。
SlackTextViewController.m:1681
- (void)setupViewConstraints { NSDictionary *views = @{@"scrollView": self.scrollViewProxy, @"autoCompletionView": self.autoCompletionView, @"typingIndicatorView": self.typingIndicatorView, @"textInputbar": self.textInputbar, }; [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[scrollView(0@750)][autoCompletionView(0)][typingIndicatorView(0)]-0@999-[textInputbar(>=0)]|" options:0 metrics:nil views:views]]; [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[scrollView]|" options:0 metrics:nil views:views]]; [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[autoCompletionView]|" options:0 metrics:nil views:views]]; [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[typingIndicatorView]|" options:0 metrics:nil views:views]]; [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[textInputbar]|" options:0 metrics:nil views:views]]; self.scrollViewHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.scrollViewProxy secondItem:nil]; self.autoCompletionViewHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.autoCompletionView secondItem:nil]; self.typingIndicatorViewHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.typingIndicatorView secondItem:nil]; self.textInputbarHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.textInputbar secondItem:nil]; self.keyboardHC = [self.view slk_constraintForAttribute:NSLayoutAttributeBottom firstItem:self.view secondItem:self.textInputbar]; self.textInputbarHC.constant = [self minimumInputbarHeight]; self.scrollViewHC.constant = [self appropriateScrollViewHeight]; if (self.isEditing) { self.textInputbarHC.constant += self.textInputbar.accessoryViewHeight; } }
-[UIView addConstraints:]
の部分はself.view
のサブビューに対する以下のような制約を追加している。- 各サブビューの高さ、およびサブビュー間の垂直方向の余白を設定
- 各サブビューの水平方向の親Viewとの余白はなし
- 各サブビュー間に制約が追加された結果、計算された制約の値をプロパティに保持する。このプロパティはレイアウトを調整する際にどんどん更新していくため重要。
キーボードの表示/非表示に伴うレイアウトの調整
キーボードはself.textInputbar
内のUITextField
がfirstResponderになったときに表示されるはずだ。キーボードが表示される直前/直後にはそれぞれUIKeyboardWillShowNotification
, UIKeyboardDidShowNotification
という通知がポストされる。そこで、この通知を監視するオブザーバーを探す。
SlackTextViewController.m:1719
- (void)registerNotifications { // Keyboard notifications [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(willShowOrHideKeyboard:) name:UIKeyboardWillShowNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(willShowOrHideKeyboard:) name:UIKeyboardWillHideNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didShowOrHideKeyboard:) name:UIKeyboardDidShowNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didShowOrHideKeyboard:) name:UIKeyboardDidHideNotification object:nil]; // ... }
UIKeyboardWillShowNotification
がポストされたとき、willShowOrHideKeyboard:
が呼ばれるようになっている。また、UIKeyboardDidShowNotification
がポストされたとき、didShowOrHideKeyboard:
が呼ばれるようになっている。
SlackTextViewController.m:1048
-[willShowOrHideKeyboard:]
の中でレイアウトの変更に関わる部分を抽出した。
- (void)willShowOrHideKeyboard:(NSNotification *)notification { // ... // Updates the height constraints' constants self.keyboardHC.constant = [self appropriateKeyboardHeight:notification]; self.scrollViewHC.constant = [self appropriateScrollViewHeight]; // ... }
self.keyboardHC
およびself.scrollViewHC
は-[setupViewConstraints]
内で設定された、それぞれの高さに対する制約だ。-[appropriateKeyboardHeight:notification]
と-[appropriateScrollViewHeight]
で適切な高さを計算しているようなので、詳細に見ていく。
SlackTextViewController.m:412
- (CGFloat)appropriateKeyboardHeight:(NSNotification *)notification { CGFloat keyboardHeight = 0.0; CGRect endFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; // ... // Sets the minimum height of the keyboard if (self.isMovingKeyboard) { if (!UI_IS_IOS8_AND_HIGHER && UI_IS_LANDSCAPE) { keyboardHeight = MIN(CGRectGetWidth([UIScreen mainScreen].bounds), CGRectGetHeight([UIScreen mainScreen].bounds)); keyboardHeight -= MAX(endFrame.origin.x, endFrame.origin.y); } else { keyboardHeight = CGRectGetHeight([UIScreen mainScreen].bounds); keyboardHeight -= endFrame.origin.y; } } else { if ([notification.name isEqualToString:UIKeyboardWillShowNotification] || [notification.name isEqualToString:UIKeyboardDidShowNotification]) { CGRect convertedRect = [self.view convertRect:endFrame toView:self.view.window]; keyboardHeight = CGRectGetHeight(convertedRect); } else { keyboardHeight = 0.0; } } // ... return keyboardHeight; }
- 引数で渡される
notification
にはUIKeyboardWillShowNotification
などが入る。これらの通知のuserInfo
のUIKeyboardFrameEndUserInfoKey
にはキーボードが表示された後のframeを表すCGRectが含まれる。 self.isMovingKeyboard
はキーボードが閉じようといるとき、または開こうとしているときにYES
となるようだ。self.isMovingKeyboard
がYES
である場合、画面全体の高さからキーボードのorigin.y
を引いた値をkeyboardHeight
としている。self.isMovingKeyboard
がNO
である場合、これからキーボードが表示されるかそれとも非表示になるかでまた分岐する。- 非表示になる場合は0。
- 表示する場合、キーボードのframeのWindow座標系を変換してその高さを取得し
keyboardHeight
としている。キーボードの座標系はデバイスの向きを考慮していないため、Window座標系かView座標系に変換する必要がある。
Slacktextviewcontroller.m:456
- (CGFloat)appropriateScrollViewHeight { CGFloat height = self.view.bounds.size.height; height -= self.keyboardHC.constant; height -= self.textInputbarHC.constant; height -= self.autoCompletionViewHC.constant; height -= self.typingIndicatorViewHC.constant; if (height < 0) return 0; else return roundf(height); }
- スクロールビューの高さは親Viewの高さからサブビューの高さを引いた余りとなっている。
SlackTextViewController.m:1060
willShowOrHideKeyboard:
に戻る。
- (void)willShowOrHideKeyboard:(NSNotification *)notification { // ... // Updates the height constraints' constants self.keyboardHC.constant = [self appropriateKeyboardHeight:notification]; self.scrollViewHC.constant = [self appropriateScrollViewHeight]; // ... }
- キーボードに関する通知によってキーボードの高さを計算し、それに合わせてスクロールビューの高さを調整していることがわかった。
- ただ、
constant
に値を代入してもすぐに反映されるわけではないため、どこかで再描画をリクエストしているはずだ。キーボードが表示されたあとに呼ばれるdidShowOrHideKeyboard:
を見る。
Slacktextviewcontroller.m:1112
-[didShowOrHideKeyboard:]
の中でレイアウトの変更に関わる部分を探す。
- (void)didShowOrHideKeyboard:(NSNotification *)notification { // ... [self reloadInputAccessoryViewIfNeeded]; [self updateKeyboardDismissModeIfNeeded]; // Very important to invalidate this flag after the keyboard is dismissed or presented self.movingKeyboard = NO; }
-[reloadInputAccessoryViewIfNeeded]
で後ほど出てくるtextView.inputAccessoryView
が初期化される。-[updatekeyboarddismissmodeifneeded]
を詳しく見る。
Slacktextviewcontroller.m:993
- (void)updateKeyboardDismissModeIfNeeded { // Skips if the keyboard panning is disabled if (!self.isKeyboardPanningEnabled) { return; } UIScrollView *scrollView = self.scrollViewProxy; UIScrollViewKeyboardDismissMode dismissMode = scrollView.keyboardDismissMode; BOOL isPannable = self.textView.inputAccessoryView ? YES : NO; // Enables the keyboard dismiss mode if (dismissMode == UIScrollViewKeyboardDismissModeNone && isPannable) { scrollView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didChangeKeyboardFrame:) name:SLKInputAccessoryViewKeyboardFrameDidChangeNotification object:nil]; } // Disables the keyboard dismiss mode else if (dismissMode == UIScrollViewKeyboardDismissModeInteractive && !isPannable) { scrollView.keyboardDismissMode = UIScrollViewKeyboardDismissModeNone; [[NSNotificationCenter defaultCenter] removeObserver:self name:SLKInputAccessoryViewKeyboardFrameDidChangeNotification object:nil]; } }
UIScrollView
のkeyboardDismissMode
というプロパティはiOS 7から登場したプロパティで、スクロールビューがドラッグされたときのキーボードの振る舞いを以下の値で指定できる。UIScrollViewKeyboardDismissModeNone
: ドラッグでキーボードを閉じない。デフォルト値。UIScrollViewKeyboardDismissModeOnDrag
: ドラッグが始まったときにキーボードを閉じる。UIScrollViewKeyboardDismissModeInteractive
: スクロールビューから下にドラッグするとキーボードを閉じ、途中で上にドラッグすると閉じるのをキャンセルできる。
UITextView
のinputAccessoryView
はキーボードの上に出てくる、よく「次へ」とか「閉じる」のようなボタンを載せるViewのこと。キーボードが表示され-[didShowOrHideKeyboard:]
が呼ばれる中で初期化されているため、isPannable
はYES
となっているはず。- よって、
keyboardDismissMode
がUIScrollViewKeyboardDismissModeInteractive
に変更され、SLKInputAccessoryViewKeyboardFrameDidChangeNotification
という通知に対してdidChangeKeyboardFrame
というメソッドが呼ばれるように登録される。- この通知は
textView
のcenter
(iOS8以降場合)またはframe
(それ未満の場合)が変更されたときに送信される。 - つまり、
textView
の描画領域が変更されたときに、-[didChangeKeyboardFrame:]
が呼ばれることになる。
- この通知は
SlackTextViewController.m:1150
- (void)didChangeKeyboardFrame:(NSNotification *)notification { // ... self.keyboardHC.constant = [self appropriateKeyboardHeight:notification]; self.scrollViewHC.constant = [self appropriateScrollViewHeight]; // ... [self.view layoutIfNeeded]; }
- 再び2つの
HC
(高さに対する制約)の値を更新している。 - 最後に
-[CALayer layoutIfNeeded]
を呼んでいる。このメソッドは描画が必要な上位レイヤーが見つからなくなるまでツリーを遡り、描画が必要なレイヤー全体を描画する。この段階で制約に対する変更が反映されることになる。
ここまでのおさらい
ここまで、キーボードの表示/非表示に伴うレイアウトの調整についてどのように実装されているのか調べてきた。キーボードの表示からレイアウトの調整が反映されるまで、おおまかに以下のような流れで処理が進行する。
- ユーザーが入力を開始する。
UIKeyboardWillShowNotification
が送信され、オブザーバーによって-[willShowOrHideKeyboard:]
が呼ばれる。キーボードの高さとスクロールビューの適切な高さが再計算され、高さの制約上の数値が更新される(ここではまだViewに反映されない)。UIKeyboardDidShowNotification
が送信され、オブザーバーによって-[didShowOrHideKeyboard:]
が呼ばれる。textView
のframe
の更新時に-[didChangeKeyboardFrame:]
を呼ぶようにオブザーバーに登録する。- 何かしらのタイミングで
textView
のframe
が更新され、オブザーバーによって-[didChangeKeyboardFrame:]
が呼ばれる。再度、キーボードとスクロールビューの高さが計算され設定される。そして、-[CALayer layoutIfNeeded]
によって変更された制約上の値がViewに反映され再描画される。
ここでtextView
のframe
が更新されるのはどのタイミングか考えてみると、2つ考えられる。
textView
の中身のテキストの行数が変更された場合。textView
の中でテキストが改行されると、当然その高さが変わるのでそれに併せてスクロールビューの高さを小さくしなければならなくなる。そういった場合に対処する実装だと思う。- ユーザーによってtextViewの位置が変更された場合。
scrollViewProxy
のkeyboardDismissMode
はUIScrollViewKeyboardDismissModeInteractive
となっているため、ユーザーの操作によってキーボードを閉じることができる。キーボードを閉じる動作をした場合、当然frame
も変更されるのでこのタイミングでも-[didChangeKeyboardFrame:]
が呼ばれることになる。
ユーザー名や絵文字の補完
続いて、ユーザー名や絵文字の補完がどのように実装されているのか調べる。ドキュメントによると、補完機能を利用する場合はSlackTextViewController
のサブクラスは以下のような実装を行う必要がある。
-[SlackTextViewController registerPrefixesForAutoCompletion:]
を呼んで自動補完を起動するプレフィックスを登録する。-[SlackTextViewController canShowAutoCompletion]
を実装して、自動補完Viewを表示するかどうかをBOOL
で返すようにする。このメソッドはテキストが入力されたとき上で登録したプレフィックスを発見した場合に呼ばれる。自動補完ViewはUITableView
のインスタンスであり、自由にカスタマイズできる。自動補完の候補はこのメソッドの中で用意する。- 自動補完Viewの高さを返すメソッド
heightForAutoCompletionView
を実装する。 - 自動補完の候補が選択された場合、自動補完Viewの
-[UITableViewDelegate tableView:didSelectRowAtIndexPath:]
が呼ばれるので、この中で-[SlackTextViewController acceptAutoCompletionWithString:]
を呼ぶと選択されたテキストが補完される。
これらのメソッドの実装を見ていくことにする。
SlackTextViewController.m:1279
- (void)registerPrefixesForAutoCompletion:(NSArray *)prefixes { NSMutableArray *array = [NSMutableArray arrayWithArray:self.registeredPrefixes]; for (NSString *prefix in prefixes) { // Skips if the prefix is not a valid string if (![prefix isKindOfClass:[NSString class]] || prefix.length == 0) { continue; } // Adds the prefix if not contained already if (![array containsObject:prefix]) { [array addObject:prefix]; } } if (_registeredPrefixes) { _registeredPrefixes = nil; } _registeredPrefixes = [[NSArray alloc] initWithArray:array]; }
- 内部的にミュータブルな配列に変換してプレフィックスを追加したあと、その結果をイミュータブルな配列に変換したものをインスタンス変数に入れている。
SlackTextViewController.m:1575
- (void)textViewDidChangeSelection:(SLKTextView *)textView { // The text view must be first responder if (![self.textView isFirstResponder]) { return; } // Skips if the loupe is visible or if there is a real text selection if (textView.isLoupeVisible || self.textView.selectedRange.length > 0) { return; } // Process the text at every caret movement [self processTextForAutoCompletion]; }
textView
の選択範囲、つまりカーソル位置が変わったとき-[UITextViewDelegate textViewDidChangeSelection:]
が呼ばれる。- この中で呼ばれる
-[SlackTextViewController processTextForAutoCompletion]
の中で、さらに呼ばれる-[SlackTextViewController handleProcessesWord:range:]
を見る。
SlackTextViewController.m:1343
- (void)handleProcessedWord:(NSString *)word range:(NSRange)range { // ... BOOL canShow = [self canShowAutoCompletion]; // Reload the tableview before showing it [self.autoCompletionView reloadData]; [self.autoCompletionView setContentOffset:CGPointZero]; [self showAutoCompletionView:canShow]; }
-[UITableView reloadData]
の前に-[SlackTextViewController canShowAutoCompletion]
が呼ばれているので、ドキュメントの通り、このタイミングで補完候補を用意する必要がある。
SlackTextViewController.m:1417
- (void)showAutoCompletionView:(BOOL)show { CGFloat viewHeight = show ? [self heightForAutoCompletionView] : 0.0; // ... self.autoCompletionViewHC.constant = viewHeight; self.autoCompleting = show; // Toggles auto-correction if requiered [self enableTypingSuggestionIfNeeded]; [self.view slk_animateLayoutIfNeededWithBounce:self.bounces options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionLayoutSubviews|UIViewAnimationOptionBeginFromCurrentState animations:NULL]; }
- ドキュメントの通り、
-[SlackTextViewController heightForAutoCompletionView]
を実装して自動補完Viewの高さを返すように実装しておくと、その値がautoCompletionViewHC.constant
にセットされる。 - 最後に
-[UIView slk_animateLayoutIfNeededWithBounce:options:animations]
によってアニメーションつきで再描画され、セットされた値が反映される。
SlackTextViewController.m:1394
最後に、選択したテキストが補完される部分の実装を見ていく。
- (void)acceptAutoCompletionWithString:(NSString *)string { if (string.length == 0) { return; } SLKTextView *textView = self.textView; NSRange range = NSMakeRange(self.foundPrefixRange.location+1, self.foundWord.length); NSRange insertionRange = [textView slk_insertText:string inRange:range]; textView.selectedRange = NSMakeRange(insertionRange.location, 0); [self cancelAutoCompletion]; [textView slk_scrollToCaretPositonAnimated:NO]; }
- 引数の
string
に選択された文字列が入っている。 -[UITextView slk_insertText:]
で選択された文字列をtextView
に挿入しているようだ。
UITextView+SLKAdditions.m:90
- (NSRange)slk_insertText:(NSString *)text inRange:(NSRange)range { // ... // Append the new string at the caret position if (range.length == 0) { NSString *leftString = [self.text substringToIndex:range.location]; NSString *rightString = [self.text substringFromIndex: range.location]; self.text = [NSString stringWithFormat:@"%@%@%@", leftString, text, rightString]; range.location += [text length]; return range; } // Some text is selected, so we replace it with the new text else if (range.location != NSNotFound && range.length > 0) { self.text = [self.text stringByReplacingCharactersInRange:range withString:text]; return NSMakeRange(range.location+[self.text rangeOfString:text].length, text.length); } // No text has been inserted, but still return the caret range return self.selectedRange; }
-[NSRange length]
が0
の場合、何も選択されていない状態なので、カーソルのある位置に足りない部分の文字列を挿入している。-[NSRange length]
が1
以上の場合、文字列が選択されている状態なので、補完されるテキストと置換する。
自分をコピーするbotを作る
自分のTwitterアカウントをコピーするbotを簡単に作れるmirror botというものを作りました。@naoty_botはこれを使って作りました。
@naoty_k なんでわざわざ大金払って太ろうとしてるの?バカなの?
— なおてぃー(bot) (@naoty_bot) 2014, 11月 10
コピーbotを作る手順
1. bot用のアカウントを作ります。
2. 人間とbotそれぞれのアカウントでTwitterアプリケーションを作ります。ここから作れます。そして、人間とbotの両方のアカウント用の「Consumer Key」「Consumer Secret」「Access Token」「Access Token Secret」を取得します(下のスクショのモザイクかかってるところです)。
追記: bot用のアプリケーションを作成する際、権限をRead and Writeにする必要があります。一度Read Onlyでアクセストークンを発行している場合は権限を変更した後もう一度発行しなおして、Herokuアプリケーションの環境変数を新しいアクセストークンに替えてください。
3. こちらのHerokuボタンを押します(別サイトに飛びます)。もしHerokuのアカウントがなければ作ってください。
4. 適当なHerokuアプリ名を入れて、「Env」の各項目に2.で取得した「Consumer Key」「Consumer Secret」「Access Token」「Access Token Secret」を入力します。人間のアカウントのものは「HUMAN*」に、botのアカウントのものは「BOT*」に入れていきます。
5.「Deploy for Free」ボタンを押してしばらく待ちます。Herokuにアプリケーションがデプロイされます。その後、アプリのダッシュボード画面で以下のようにdynosを1xにすると、アプリケーションが起動します。
6.終わり。
コピーbotの機能
- 過去のtweetからランダムに選んで投稿します。ランダムに選ぶとき、現在の時間帯を考慮します。だから、朝には朝っぽいことをtweetするはずです。
- 一日のtweet数やどの時間帯にtweetされる傾向があるかを計算し、そのパターンに従います。例えば、一日にたくさんtweetする人のbotはたくさんtweetしますし、あなたが通勤時間と帰宅時間にtweetする傾向があると、botもその時間帯にtweetする確率が高いです。
- 話しかけるとreplyを返します。replyは過去にあなたがその人に返したreplyからランダムに選ばれます。
- あなたがfavりやすいtweetを学習し、favります。
技術的な話
tweetするタイミングの決定
人間のtweetはHeroku Postgresqlのfree planの上限である10000レコードまで保存されます(上限を超えると古い順に消します)。そのとき、一日の中で何分目に投稿されたかを同時に記録します。たとえば、01:00の投稿は60分目だし、02:00の投稿は120分目、23:59の投稿は1439分目となります。すると、投稿数が多い分と少ない分がわかります。なので、n分に投稿される確率=n分の投稿数/総投稿数
を計算することができます。ここから0分から1359分までの確率分布を作ることができます。この確率分布を累積分布にすると実装が簡単になります。そして、0から1までの乱数を生成して累積分布上の重なる分数をbotがtweetする分数として決定します。これを一日にtweetする回数分行い、その日tweetする分を事前に決定しておきます。
以上のようなことを行っているのが./lib/mirror_bot/scheduler.rb
というファイルです。
favりやすいtweetの学習と分類
人間は大量に流れてくるtweetの中から特定のtweetだけを選んでfavっています。この行動は大量のメールの中から迷惑メールだけをゴミ箱送りにする行動と似ています。つまり、大量のデータから特定のカテゴリーに含まれるものを識別する、という問題に一般化できると考えました。そこで、スパムフィルタリングと同じアルゴリズムで、大量のtweetから特定のtweetだけをfavoriteというカテゴリーに分類する実装をしました。
スパムフィルタリングの実装はごく普通のベイジアンフィルタです。簡単に言ってしまうと、favられたtweetに含まれやすい単語とか含まれにくい単語を調べていくということをしていきます。形態素解析にはokuraを使いました。とても便利でした。各単語の各カテゴリーに含まれた回数はRedis(redistogo)に保存しています。
以上のようなことを行っているのが./lib/mirror_bot/classifier.rb
というファイルです。
事前学習とHerokuボタン
以上の2つのモジュール、schedulerとclassifierを機能させるには事前に多くのデータを学習させることが必要です。schedulerについては過去3,200件のtweet、classifierについては800件ずつのfavったtweetとfavってないtweetを学習させています。これを行っているのがtrainerです。trainerは./lib/mirror_bot/trainer.rb
で定義されており、./bin/mirror_bot
スクリプトから実行します。
Herokuボタンからbotをデプロイする場合、デプロイして起動するまでにtrainerを実行する必要があります。Herokuボタンによるデプロイを設定するapp.json
にはscripts
という項目があり、こうしたセットアップのための設定が可能です。以下のように指定するだけです。
{ "scripts": { "postdeploy": "bundle exec sequel -m migrations $DATABASE_URL && bin/mirror_bot train scheduler && bin/mirror_bot train classifier" } }
これでデプロイしてから起動する直前に事前学習を実行することができました。
なぜ作ったか
最近、機械学習とか自然言語処理に興味が出てきて勉強をしはじめたのですが、何か具体的な目標がほしいと思って「ちょっと賢いbot」を作ることにしました。いろいろ試行錯誤した結果、自分の行動パターンを学習してまねするbotを作ることにしました。
得られた知見
- botっぽいアイコンはhttp://robohash.org/で生成できて便利。
- botによる発言を自動生成させようと試行錯誤したけど、結局コピーにすることにしました。まず、マルコフ連鎖を使った文章生成はbotっぽいけど人間らしさはないので、今回は却下。次に、word2vecを使って文章に含まれる単語を類義語と入れ替えることで似たような文章の生成を試みました。word2vecをRubyから使う術がないので、別プロセスでPythonのgensimを使った類義語サーバーを立ててプロセス間通信でRubyに返すみたいな実装をしてみました。ですが、これだとそもそもHerokuの無料枠では不可能でした。さらに、word2vecで使われるモデルファイルが大きすぎてディスクにのっかりません。VPSで挑戦してみましたが、今度は1GBのメモリにのっかりきらずに動きませんでした。思ったほど意味の通じる文章を生成できるわけでもなかったので、この方法は諦めました。
- herokuのオペレーションをスムーズに進めるときにhttps://github.com/ddollar/heroku-configとhttps://github.com/ddollar/heroku-redis-cliが便利でした。
参考
- 作者: Toby Segaran,當山仁健,鴨澤眞夫
- 出版社/メーカー: オライリージャパン
- 発売日: 2008/07/25
- メディア: 大型本
- 購入: 91人 クリック: 2,220回
- この商品を含むブログ (277件) を見る
はじめてのAIプログラミング―C言語で作る人工知能と人工無能
- 作者: 小高知宏
- 出版社/メーカー: オーム社
- 発売日: 2006/10
- メディア: 単行本
- クリック: 112回
- この商品を含むブログ (24件) を見る
- word2vecによる自然言語処理
pod installしたらgit cloneしてくれるヤツ書いた
ghqを使ったローカルリポジトリの統一的・効率的な管理について - delirious thoughtsを拝見して良さそうだったので、iOS開発にも持ち込むためCocoaPodsのプラグインを書いた。20行くらいしか書いてないし、ghqとの連携もまだ実装できてないけど、取り急ぎ。
使い方
$ gem install cocoapods-src
cocoapodsは入っている前提で、cocoapods-srcをインストールする。
$ pod install
インストールすると、あとはpod install
すれば勝手にpodsをgit clone
してくれる。今のところ~/.cocoapods/src/
に以下のような感じでダウンロードされる。
$ tree ~/.cocoapods/src -I .git -L 2 .cocoapods/src ├── .DS_Store ├── AFNetworking │ ├── .cocoadocs.yml │ ├── .gitignore │ ├── .travis.yml │ ├── AFNetworking │ ├── AFNetworking.podspec │ ├── AFNetworking.xcworkspace │ ├── CHANGES │ ├── CONTRIBUTING.md │ ├── Example │ ├── LICENSE │ ├── README.md │ ├── Rakefile │ ├── Tests │ └── UIKit+AFNetworking
今後
追記(10/22)
0.2.0にアップデートして、ghqと連携できるようになった。
~/.podrc
、~/.cocoapods/.podrc
、./.podrc
のいずれかに以下のような設定を書くとgit clone
の代わりにghqを使ってダウンロードする。
cocoapods-src_use_ghq: true
「集合知プログラミング」を読んでる
「集合知プログラミング」という本を先週から読み始めた。この本は機械学習をテーマとしていて、現実にありそうな問題(例えば、映画の評点から似ているユーザーを推薦するとか、数ある旅行プランの組み合わせから最適なプランを選択するとか)を題材にさまざまなアルゴリズムをチュートリアル形式で学んでいける。登場するサンプルコードはすべてPythonで書かれているため、まずこの本を読む前に軽くPythonについて勉強した。機械学習の本というと、むずかしい数式がたくさんでてきて近寄りがたいイメージがあるのだけど、この本についてはほとんど数式は出てこないので、カジュアルに読み進められる。
まだ半分も進んでないけど、その中で一番おもしろかったのが最適化アルゴリズムの話だった。ある最適な値を求めたいとき、「となりあう値と比較して良い方を選択する」というのを繰り返していくとどこか最適な値で落ち着くはずというアルゴリズム(ヒルクライム)があるのだけど、これだと局所最適に陥ってしまうということを最近勉強した。つまり、全体を見渡すともっと最適な値があるのだけど、近くの値とだけ比較しているとそれを見逃してしまうということだ。また、別のアルゴリズム(模擬アニーリング)は、試行回数が少ないうちは悪い結果を受け入れ、回数を経るにつれてその悪い結果を受け入れ難くしていくことで局所最適を回避する。
これはいろんなところで当てはまりそうな考え方だなと思った。見える範囲、理解できる範囲だけで最適な選択をとろうとするとより適切な解を見落としてしまう。若いうちは結果が悪かろうともそれを受け止めることで局所最適を回避し全体最適に近づくことができるのかもしれない。
この本を読もうと思った理由としては、いろんな領域と機械学習を組み合わせるとなんか面白いものが作れそうな気がしたから。これまで自分が作ってきたソフトウェアの中で自分自身気に入っているものの多くは別の領域のアイデアを持ち込むところから生まれている。だから、組み合わせの可能性が大きい領域を何か新しく学びたいと思ったときに機械学習というものが浮かんでてきてこの本から取り組んでみることにした。今は「iOS x 機械学習」みたいな掛け合わせで何か面白いものが作れないか考えている。
- 作者: Toby Segaran,當山仁健,鴨澤眞夫
- 出版社/メーカー: オライリージャパン
- 発売日: 2008/07/25
- メディア: 大型本
- 購入: 91人 クリック: 2,220回
- この商品を含むブログ (277件) を見る
ストリームを利用したローパスフィルタの実装
このスクリーンショットに映された2つの線は共にiPhoneの加速度センサーの値を表しており、下の緑が加工していない生データ、上の青い線がローパスフィルタという仕組みで揺れを除去したデータだ。
以前の記事でSwiftを使ったストリームの実装をしてみたのだけど、その使いどころを考えてみたところセンサーデータの加工にストリームという概念が適しているのではないかと思いついた。センサーから送られてくるデータは連続的で、その加工には複雑な計算を要するためだ。
そこで、加速度センサーをグラフに表示する簡単なアプリを作ってみて、生データとストリームを使って加工したデータを視覚的に表現してみることにした。その結果が上のスクリーンショットとなる。今回はローパスフィルタと呼ばれる手法を用いて生データを加工した。そちらの方面にはまるっきり分からないのだけど、以下のようなとてもシンプルなアルゴリズムでデータを加工できるとのことだったので利用した。
今回の加工したデータ = 前回の加工したデータ * 0.9 + 今回の生データ * 0.1
このローパスフィルタを以前開発したストリームライブラリで実装してみる。
var x: [CGFloat] = [] var filteredX: [CGFloat] = [] let xStream = Stream<CGFloat>()
まず、生データと加工したデータをグラフに描画するための配列と生データを扱うストリームを用意する。加速度センサーから値を取得する度にこのストリームに値を出力していく。
override func viewDidLoad() { // ... motionManager.startAccelerometerUpdatesToQueue(NSOperationQueue.currentQueue(), withHandler: accelerometerHandler) } private func accelerometerHandler(data: CMAccelerometerData!, error: NSError!) { xStream.publish(CGFloat(data.acceleration.x)) }
ストリームに渡された生データをグラフに描画するための配列に入れるため、値が出力されたときに実行される関数を登録しておく。これで生データが出力されたときはいつでもこの関数が実行される。
override func viewDidLoad() { // ... xStream.subscribe { [unowned self] message in self.x.append(message) } }
続いて、上のストリームに出力された生データを加工して出力する別のストリームを作成する。これはscan
関数を利用することで簡単に実現できる。scan
関数は「前回出力された値と今回出力された値を使って、新たな値を出力するストリーム」を簡単に作成できる。なので、上で示したローパスフィルタのアルゴリズムを以下のように実装することができる。
override func viewDidLoad() { // ... xStream.subscribe { [unowned self] message in self.x.append(message) } let filteredStream: Stream<CGFloat> = xStream.scan(0) { previousMessage, message in return previousMessage * 0.9 + message * 0.1 } }
最後に、加工した値の出力を見張ってグラフ描画用の配列に追加するための関数を登録しておく。
override func viewDidLoad() { // ... xStream.subscribe { [unowned self] message in self.x.append(message) } let filteredStream: Stream<CGFloat> = xStream.scan(0) { previousMessage, message in return previousMessage * 0.9 + message * 0.1 }.subscribe { [unowned self] message in self.filteredX.append(message) // ... } }
このように、ストリームの性質やストリームを扱う様々な関数を利用すると、簡単にセンサーデータを扱うプログラムを実装することができた。アプリのソースコードはgithubにアップしてあるので、参考にしてほしい。