dely engineering blog

レシピ動画サービス「kurashiru」を運営するdelyのテックブログ

エンジニアがCSと上手く連携するためのコミュニケーション

この記事はdely Advent Calendar 2018の17日目の投稿です。
昨日は、プロダクトデザイナーのミカサ トシキ(@acke_red)が「Fluid Interfaces実践 - なめらかなUIデザインを実現する」というタイトルで投稿しました。

Qiita: https://qiita.com/advent-calendar/2018/dely
Adventar: https://adventar.org/calendars/3535

はじめに

こんにちは、delyでiOSエンジニアをしている堀口(@takaoh717)です。
今日は、普段僕が行っているCS(カスタマーサクセス)担当者との取り組みについてご紹介しようと思います。
僕の普段のメイン業務はiOSアプリの開発ですが、CSの技術的なサポートも毎日行っています。
内容としては、主にクラシルの利用に関する問い合わせが来た際にCS担当者が分からないことの解説を行ったり対応方法を教えてあげたりしています。
今回は1年ほどCS担当者とのやり取りを行った中で、エンジニアがこういうことを意識してコミュニケーションすると良いなと感じたことを挙げてみたいと思います。

CSとのコミュニケーションで意識して良かったこと

ボールを宙に浮かせない

弊社ではクラシルの問い合わせ対応においてはCS用のツールを特に使用していません。
現在は主に以下のツールのみで運用を行っています。
(掲載しているレシピに関する質問への対応は社内ツールを使用しています。)

  • Gmail
    • ユーザとのやり取り
  • Slack
    • 社員同士のやり取り
  • Qiita:Team
    • 対応テンプレ作成やナレッジの蓄積

基本的にはエンジニアとCS担当者とのやり取りはSlack上で行われますが、管理ツールを使用していないと、たまに以下のようなやり取りが発生します。

CS < ユーザからアプリが正しく動作しないと問い合わせが来ましたが、何か分かりますか?
エンジニアA < うーん、こちらでは再現しませんね・・・
エンジニアB < 最近ここ特に触ってないですよね・・・
エンジニアA < そうですね、謎ですね・・・

このまま問題が放置されて、しばらく時間が経ってしまうということが以前はたまに起きてました。 これだと、CS担当者も対応が分からず不安なままですし、何よりユーザを待たせてしまいます。 このように、原因がすぐに特定できない場合は、CS担当者にその旨を伝えてユーザに返信をしてもらうようにします。

↓改善後はこのようなコミュニケーションになります

CS < ユーザからアプリが正しく動作しないと問い合わせが来ましたが、何か分かりますか?
エンジニアA < うーん、こちらでは再現しませんね・・・
エンジニアB < 最近ここ特に触ってないですよね・・・
エンジニアA < じゃあ一旦ユーザには調査する旨を伝えてIssue作成しておきます。
       @CS  ユーザさんに調査する旨をお伝え下さい。

質問しやすい状態を作る

CSの対応ではスピード感がとても重要だと思います。
内容にもよりますが、ユーザの問題の解決は早いに越したことはありません。
そして、素早い対応を行うには、躊躇せずに質問ができる状態を作っておくことが大切です。
そこで、どういう風にすれば素早く質問をしてもらえる状況を作れるかを考えて以下のようなことを行っています。

  • 質問の仕方をフォーマット化する
    • 質問したい問い合わせのユーザのメールアドレスのみをSlackに投稿し、スレッド内に聞きたいことを書く
      • フォーマットが自由になっていると、「お忙しい所すみませんが・・・」みたいなやり取りが無駄に発生しがちです。簡潔なフォーマットが決まっていれば、無駄なやり取りに気を使う精神的コストや文章を考える負担をかけることがありません。
  • 週1回は対面でのコミュニケーションの場を設ける
    • 問い合わせが来たわけではないけれど気になっていることなどをここで質問してもらう
      • オンラインのやり取りだと中途半端な理解で済ませがちなことも、きちんと納得がいくまで説明する機会が作れます。
      • サービスに関する説明は画面を見せながら説明すると理解しやすいことが多いです
  • Slackでやり取りするときは絵文字や!などをなるべく使う
    • テキストのみでの会話だと感情が読み取りにくいため、相手の顔色を伺いながら話しかけるような状態が生まれやすく、生産性の低下に繋がります。

対応方法だけではなく、きちんと仕組みを説明する

不具合があったときに、非エンジニアの人でも理解できる言葉を使って説明することも意識しています。サーバーデータベースなどの技術的な単語は使わずになるべく一般の人が理解できる言葉に置き換えながら説明を行います。

その一例として、クラシルで実際に起こった例をご紹介します。

起きたこと:
iOSアプリの内部のデータベースの一部のデータの保存先を変更した際に、変更する予定じゃなかったデータ(お気に入り)の読み書き先も変わってしまい、データが表示されなくなってしまった。
この現象を担当者に理解してもらうために以下の説明をしました。

クラシルのお気に入りはアプリの中にデータを保存しています。  
イメージとしては、アプリの内部にWindowsのマイドキュメントのようなデータを保存する仕組みがあると思ってください。
マイドキュメントの中には「お気に入りフォルダ」があります。ユーザがお気に入りボタンを押したときはレシピがこのお気に入りフォルダに入ります。
これが普段のクラシルの状態です。
今回のパターンを説明します。今回はお気に入りとは違うデータを保存しないといけなくなりました。
そこで、マイドキュメントの中に「新しいフォルダ」を作成して、ここにデータを保存するようにしました。
しかし、開発上のミスで、ユーザがお気に入り一覧画面を開いたときに、今までは「お気に入りフォルダ」を開いていたんですが、
「新しいフォルダ」を開くようになってしまいました。

これをちゃんと行うことで以下のような効果があると思います。

  • きちんと仕組みを理解してもらうことで、納得感を持ってユーザに説明することができるようになる
  • 問い合わせ対応の質(説明の内容やスピード)が向上する
  • 問題がどういう状態のものか理解することで、似ているけれど違う要因の問い合わせがきたときに判別ができる
  • 内容は同じだけど、問い合わせの文章が異なっていて同じ要因かどうか判別しづらいものが判別できるようになる

対応方法を共有する場合は、自分がその解に至った経緯やロジックを共有する

何らかのサポートをするときに解決方法を共有しただけでは、次に同じ問題が発生した場合に解決することができない可能性が高いです。 そのため、自分がどうやってその解を導き出したかという経緯も共有してあげると良いと思います。 例えば、こういう感じでコミュニケーションをしています。

  • 「ユーザさんが「昨日から〜〜ができない」と仰っているので◯◯ではなくて、△△に該当すると思います」
  • 「Slackやドキュメントで「〇〇」で検索をしたら、こういう結果が出てきたので△△の対応が適切だと思います」

こういった共有を行うことで、次に同じような問題が発生した際に対応方法は分からなくても調査を行うことができますし、 全く別の問題が発生した場合の調査方法の幅も広がります。

まとめ

以上、自分としてもまだまだ改善できることはあると思っていますが、やってみるとチーム的にもCSの対応としても良くなったんじゃないかなと思っています。 ここで書いた内容に関して、まとめると総じて重要なことは以下のことだと思います。

・ちゃんと納得感を持った上でユーザへの対応行う

・素早く的確にユーザの問題を解決できるようにする

明日はSREの井上が投稿します。こちらもぜひご覧ください!

社内SQL勉強会を開催しました

f:id:sakura818uuu:20181211164246p:plain

こんにちは、検索エンジニアのsakura (@818uuu) です。
先日、営業さん向けにSQL勉強会を行いました。開催してみて難しかったことや得た知見などを紹介します。


この記事はdely Advent Calendar 2018の14日目の記事です🎅🎄
Adventar : dely Advent Calendar 2018 - Adventar
Qiita : dely Advent Calendar 2018 - Qiita

13日目の記事は、Androidエンジニア kenzoさんの「【Android】ViewPagerのページ切り替えをいい感じにする 」でした。ぜひ読んでみてください。

tech.dely.jp

なぜやろうと思ったか

勉強会を開催しようと思ったきっかけは、営業さんの日報を見ていると「SQLや分析に興味がある」と時々書かれていてなにか助けになれないかなーと思ったからです。

エンジニア以外でもSQL学ぶことのメリットはこちらにまとまっていましたのでよければご覧ください。

paiza.hatenablog.com

行う目的やゴールを明確にする

勉強会の事前準備をしてる中で教えていただいたことがあります。
それは
「この勉強会の目的はなにか。ゴールをどういう指標にするのか。」
を明確にするということです。
そうすることで 、
・参加者にとって参加するか/しないかを決める判断指標の1つになる
・スピーカーにとって何を伝えたらいいのかを明確にできる
になるからです。

いままで個人で勉強会を開催したことはあるのですが、参加者のゴールは決めたことがなかったのでとても参考になりました。
これから勉強会を行う際も気をつけていこうと思います。

f:id:sakura818uuu:20181214101242j:plain
今回の勉強会のゴール

開催概要

勉強会には営業さんを中心に約10名にご参加していただきました🎊
そして、開発部やマーケティング部の方にご協力いただき計3名がスピーカーをしました。

参加者の皆さんは積極的に質問していただいたり、スピーカーの方はとても参考になる資料作成をしていただいたり、コーポレート部さんは自発的に勉強会の様子を動画で撮影してくださいました。

勉強会は色んな方の協力があってこそ成立するんだなと改めて思いました。

f:id:sakura818uuu:20181211160921j:plainf:id:sakura818uuu:20181211160452j:plain
勉強会で紹介した資料の一部

知見まとめ

勉強会を開催したことによって学んだ知見をslackにまとめました。

f:id:sakura818uuu:20181211144758p:plain

いい知見をたくさん得ることができたので次の勉強会を開催する際に活かします。

これからのdely Advent Calendar 2018もぜひお楽しみにしていてください〜!

【Android】ViewPagerのページ切り替えをいい感じにする

こんにちは。delyでAndroidのエンジニアをしているkenzoです。
この記事はdely Advent Calendar 2018の13日目の記事です。

Qiita: https://qiita.com/advent-calendar/2018/dely Adventar: https://adventar.org/calendars/3535

 

昨日はkurashiruのwebグロース全般を担当しているinternet_ghostがこちらの記事を書きました。
クラシルでのSEO施策についてや、外部の方が気になりそうなポイントについて書かれています。ぜひご覧ください!

はじめに

今日は先日の記事に引き続き、AndroidアプリのViewPagerをいい感じの動きにしていく際にやったことをご紹介します。
今回はページ切り替え時の動きをいい感じにしていきたいと思います。

※ 注意: 今回の記事を読んで試しに実装する場合は一旦下まで読んでから実装してください。上の方のコード使わなくていいかもなので。

ViewPagerのページ切り替えをいい感じに

先日の記事で作成したサンプルアプリに少し機能を追加します。
ページを切り替えるボタン「←」「→」の設置と★のタップではじめに戻るようにします。

ページを切り替える処理を実装

ViewPagersetCurrentItemを使います。

star.setOnClickListener { viewPager.currentItem = 0 }

f:id:kenzo_aiue:20181211145045g:plain

これでページが切り替わるようになりました。

もう少しゆっくりページを切り替えたい

ページが切り替わるようになりましたが、ちょっと切り替わるスピード早いですよね?ズビュンって感じ。
なので、少しゆっくり切り替わるようにします。

ViewPagerを継承してCustomViewPagerを作成します。

class CustomViewPager @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : ViewPager(context, attrs) {

    companion object {
        private const val CUSTOM_DURATION: Int = 1000
    }

    init {
        ViewPager::class.java.getDeclaredField("mScroller").run {
            isAccessible = true
            set(this@CustomViewPager, CustomScroller(context))
        }
    }

    private class CustomScroller(context: Context) : Scroller(context, FastOutSlowInInterpolator()) {

        override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) {
            super.startScroll(startX, startY, dx, dy, CUSTOM_DURATION)
        }
    }
}

ViewPagerで使用されるScrollerを自分で作成してセットします。 durationを1000msにしたので、1秒でページが切り替わるようになります。

f:id:kenzo_aiue:20181211145137g:plain

これでページがゆっくり切り替わるようになりました。

スワイプするときは今までどおりにしたい

ページがゆっくり切り替わるようになったと思ったら、今度はスワイプする際におかしな挙動をするようになってしまいました。

f:id:kenzo_aiue:20181211150722g:plain

ボタンを押した時はゆっくり切り替わってほしいけど、スワイプする時は今までどおりに動いてほしいですよね。
なので、先程作成したCustomViewPagerに手を入れてsetCurrentItemを呼ぶ時だけゆっくり切り替わるようにします。

class CustomViewPager @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : ViewPager(context, attrs) {

    companion object {
        private const val CUSTOM_DURATION: Int = 1000
    }

    private val interpolator: CustomInterpolator = CustomInterpolator()
    private val scroller: CustomScroller = CustomScroller(context, interpolator)

    init {
        ViewPager::class.java.getDeclaredField("mScroller").run {
            isAccessible = true
            set(this@CustomViewPager, scroller)
        }
        addOnPageChangeListener(object : OnPageChangeListener {

            override fun onPageScrollStateChanged(state: Int) {
                when (state) {
                    SCROLL_STATE_IDLE, SCROLL_STATE_DRAGGING -> { // ページ切り替えが終わったら、また、切り替え中にスワイプした際に元の挙動で切り替わるように
                        interpolator.isCustom = false
                        scroller.isCustom = false
                    }
                }
            }

            override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit

            override fun onPageSelected(position: Int) = Unit
        })
    }

    override fun setCurrentItem(item: Int) {
        interpolator.isCustom = true
        scroller.isCustom = true
        super.setCurrentItem(item)
    }

    private class CustomScroller(context: Context, interpolator: Interpolator) :
        Scroller(context, interpolator) {

        var isCustom: Boolean = false

        override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) {
            super.startScroll(startX, startY, dx, dy, if (isCustom) CUSTOM_DURATION else duration)
        }
    }

    private class CustomInterpolator : FastOutSlowInInterpolator() {

        private val default: Interpolator = Interpolator { input -> (input - 1).pow(5) + 1 } // ViewPager内で作成されているInterpolatorをここで再実装
        var isCustom: Boolean = false

        override fun getInterpolation(input: Float): Float =
            if (isCustom) super.getInterpolation(input) else default.getInterpolation(input)
    }
}

ちょっと長くなってしまいましたが、こんな感じになるよう変更しています。

  • ゆっくりとデフォルト両方の動きができるScrollerInterpolatorを作成(isCustomtrueでゆっくり)
  • ボタンで切り替えを行うタイミングでisCustomtrue
  • 切り替えが終わったらisCustomfalse
  • ゆっくり切り替え中にスワイプした場合にもデフォルトの動きができるようにisCustomfalse

f:id:kenzo_aiue:20181211155638g:plain

これで、ボタンで切り替える時はゆっくり、スワイプした時は今までどおりにページが切り替わるようになりました。

この実装でいいのか

アプリの挙動だけ見ると、うまいこと動くようになりました。
しかし、今回作成したCustomViewPagerはリフレクションでmScrollerにアクセスしています。
ViewPagerの中身の実装が変わってmScrollerがなくなる可能性も0ではないので、できれば違う方法で実現したいところです。*1

隣のページに移動するだけでいいなら

今度は先程作成したCustomViewPagerではなくViewPagerに戻し、別の実装をしてみます。
ViewPagerfakeDragBegin fakeDragEnd fakeDragByを用いてページを切り替えます。
これを用いると、隣のページへのスワイプを模した動きをさせることができます。(本記事ではfake dragと呼ぶことにします)

private var prevDragPosition = 0

override fun onCreate(savedInstanceState: Bundle?) {

    /* 省略 */

    left.setOnClickListener {
        if (viewPager.currentItem > 0) fakeDrag(false)
    }
    right.setOnClickListener {
        if (viewPager.currentItem + 1 < viewPager.adapter?.count ?: 0) fakeDrag(true)
    }
}

private fun fakeDrag(forward: Boolean) {
    if (prevDragPosition == 0 && viewPager.beginFakeDrag()) {
        ValueAnimator.ofInt(0, viewPager.width).apply {
            duration = 1000L
            interpolator = FastOutSlowInInterpolator()
            addListener(object : Animator.AnimatorListener {

                override fun onAnimationStart(animation: Animator?) = Unit

                override fun onAnimationEnd(animation: Animator?) {
                    viewPager.endFakeDrag()
                    prevDragPosition = 0
                }

                override fun onAnimationCancel(animation: Animator?) {
                    viewPager.endFakeDrag()
                    prevDragPosition = 0
                }

                override fun onAnimationRepeat(animation: Animator?) = Unit
            })
            addUpdateListener {
                val dragPosition: Int = it.animatedValue as Int
                val dragOffset: Float = ((dragPosition - prevDragPosition) * if (forward) -1 else 1).toFloat()
                prevDragPosition = dragPosition
                viewPager.fakeDragBy(dragOffset)
            }
        }.start()
    }
}

このようなことをしています。

  • beginFakeDragでfake dragを始める
  • いい感じのdurationinterpolatorValueAnimatorを用意
  • fakeDragByで少しずつスクロールさせる
  • ViewPagerwidth分のスクロールが終わったらendFakeDragでfake dragを終了

f:id:kenzo_aiue:20181211154305g:plain

このようにCustomViewPagerと同様の挙動で隣のページへ切り替えることができました。

おわりに

今回と前回使ったサンプルアプリのソースはこちらです。
先日の記事「Androidにいい感じの動きをさせていく話(ViewPagerとその他Viewとの連動」と合わせてみなさまのアプリ上のViewPagerの動き方をよりよいものにしていくための一助になれば幸いです。

明日は検索エンジニアsakuraの「社内SQL勉強会を開催しました」です。お楽しみに!

*1:リフレクションでmScrollerを変更するやり方はぐぐるといっぱい出てくるし、やたら使われてそうなので、そうそう変えられないかなーとは思いますけど

kurashiruが取り組むSEOのおはなし

おはようございます。

delyでkurashiruのwebグロース全般を担当しているinternet_ghostです。

この記事はdely Advent Calendar 2018の12日目の記事です。

Qiita: https://qiita.com/advent-calendar/2018/dely

Adventar: https://adventar.org/calendars/3535

 

昨日はサーバーサイドエンジニア兼、新米slackBot整備士のjoe (@joooee0000) さんが

tech.dely.jp

という記事で弊社の「哲学部」のslack botの運用について書いてくれました。

ちなみに哲学部、割と人気です。slack bot使いこなせると日々のKPI追ったりなど超活用できると思うので、興味のある方はぜひ読んでみてください!

さて、はじめに

アドベントカレンダーではあまり見たことがないのですが、本日はがっつりSEOの話をします。

刺さる人には刺さるし、刺さらない人には全然刺さらなさそうですが、あんま気にしないで書いていこうと思います。

さて、kurashiruは一般的にはレシピ動画の「アプリ」というイメージが強く、そもそもWebやってるんだね!知らなかった!」と言われることが多いです。

(今も結構言われます)

そんな中、「SEOをやっています!」というと結構驚かれることが多いのでせっかくなのでkurashiru webで今までなにをしてきたか?というのを少しだけ紹介します。

 

kurashiru webの現状

f:id:internet_ghost:20181212094941p:plain

 

みんな大好きGoogle Analyticsの2018年1月〜201811月末までのデータです。

ざっくりこの1年でトラフィックは6~7倍まで増えました。やったね!😆😆😆

正直アルゴリズムの影響がでかいんですが、それでも伸びてくれて嬉しいです。

(9月あたりに凹んでいるのは、モロにアルゴリズムの影響です)


やった施策 / やっている施策

SEOの施策については割と大胆にドコドコとやってます。
全部説明すると長いので、箇条書きでまとめます!

内部リンクの改善

・無数にあった、タグとカテゴリの重複ページを正確なページに301リダイレクト

・紐づいているレシピが少ないカテゴリやタグはnoindex

・カテゴリに紐付いているレシピが最適かどうか、とにかく見直す手動でつけ直し

・レシピ同士、カテゴリ同士など横・縦関係の内部リンク設計の整備

・そもそもカテゴリリニューアルしちゃおうぜ etc...

UX・パフォーマンス改善・SPA

・kurashiru webをSPA化しました

(※なんでそもそもSPAにしたの?という話はまたどこかで...)

 

・検索体験を考えた上でのUX改善 / モバイル最適化への移行
(Search Experience Optimizationとか言われてるらしいですね最近)

コンテンツ拡充とそれを支えるオペレーション作りや管理画面の作成

・動画だけではない、ユーザーが困っている料理に関する悩みやわからないことを解決するためのコンテンツの制作(仕込んでいるので、そのうち出ると思います)

 

よく聞かれること

外の人と話すとよく聞かれる事についても2つだけ書いておきます。

1.動画のサービスなのにどうやってSEOやるの?

「コンテンツが動画かどうか?」はあまり関係なく、Googleのクローラーは主にテキスト情報を認識した上でランキングの評価しています。
(もちろんそこだけじゃなくて、様々な指標の元ですけど)

※「動画」の認識をさせるために、構造化データの実装などは取り組んでいます。

下の記事に検索結果のスクショを交えて書いているんですが、一時期は動画コンテンツが検索結果によく現れる時期がありました。

Googleもいろんな検索体験に基づいて検索結果をテストしています。

 

note.mu
ただし、レシピコンテンツは差別化がとても難しいです。

材料・手順・ワンポイントそれ以上の答えを特にユーザーが求めていないからですね。

じゃあどうしているの?というと次の質問につながってきます。

2.なんで開発部でSEOやってるの? 

てことはコンテンツ以外の部分で、差別化を考えなければならないと考えました。
結局のところ行き着いた答えはすごくシンプルに

「検索エンジンとユーザーの両軸にとって最高のプロダクトを磨き上げること」 

だったので、そこにフォーカスするべく、開発部に異動しちゃいました。

(※元々はマーケティング部に所属していました。)

SEOはマーケティング戦略や集客チャネルとして語られることが多いのですが、

1.プロダクト開発が軸になるSEO

2.コンテンツマーケティングが軸になるSEO


この二つに大きく分類できると考えています。

かつ最近のSEOは、2のコンテンツの話題が多くなってるなと感じています。


ただ、kurashiru webにとっては1の「プロダクト開発」の軸がサービスとしても、SEOをグロースさせるという意味でもかなり大事だという戦略の中で悩んだ結果...

マーケティング部としてSEOの事にガミガミ言うパートナー的存在として携わるのではなく、開発部としてプロダクトの全体戦略を考えつつ、SEOをはじめとしたグロース施策を進めていくという体制に舵を切ってみました。

 

なので、kurashiru webのPM業も兼任しています。

うまく行くかどうかわからないんですが、今の所いい感じです。(多分...)

 

最後に

今日はkurashiru web、特にSEOの部分にフォーカスを当てて記事を書いてみました。

・もっと施策の中身や背景の話を聞いてみたい!
・ここもっと詳しく教えて欲しい

などがあればいつでも話すので、ぜひともTwitterでお話しかけください。

 

twitter.com

 

明日は、Androidエンジニアのkenzoによる「【Android】ViewPagerのページ切り替えをいい感じにする」です! お楽しみに〜〜

好きな技術を使って作る!くだらないslackBot運用のすゝめ

こんにちは。サーバーサイドエンジニア兼、新米slackBot整備士のjoe (@joooee0000) です。 本記事はdely Advent Calendar 2018の11日目の記事です。

Qiita: https://qiita.com/advent-calendar/2018/dely
Adventar: https://adventar.org/calendars/3535

前日は、iOSデザインエンジニアの John が という記事でXcodeのDebugging View Hierarchiesの紹介をしました!

はじめに

皆さんは、触ったことのない技術をプライベートで触ってみたいけど、結局なにを作ろう。と悩んだ結果座学のみになってしまった経験はありませんか?

f:id:joe0000:20181211120039p:plain

そんな方がいたら、くだらないslackBotを作って運用をしてみることをおすすめします!

最近、哲学研究会(怪しげ)という社内クラブのslackチャンネルで哲学slackBotの運用をはじめました。 数百種類の哲学名言の中から毎日ランダムで1件名言を呟いてくれるslackBotです。

f:id:joe0000:20181211123808p:plain

そのslackBot活動がとても良い勉強になったので、このAdventCalendarではくだらないslackBotを運用するメリットやオススメのslackBotの始め方を紹介します。

また、実際に得たslackBotに関する知見や具体的な実装方法について、次回のdely Advent Calendar 2018の19日目に、サーバーレス+Go言語で作るインタラクティブな哲学slackBot というタイトルで公開します。

くだらないslackBotを運用するとなにがいいのか

(以下、slackBotのことをBotと呼ばせていただきます。)

くだらないBotを運用する一番のメリットは、楽しく技術の勉強ができるという点です。

  • 失敗をおそれず色々な技術を試せる
  • 工夫次第でどんな技術でも自由に取り入れられる
  • 得た知見を業務に役立てられる
  • 車輪の再開発/overkillが気にならない
  • フロント側のコードを書かなくても目に見える楽しいアウトプットがある

中でも、

  • 楽しいアウトプットがある

ここはモチベーションを維持する上でとても大事です。

特に、ミドルウェアやサーバー周りを触ってみたい場合は、使いたい技術は決まっていても目的(アウトプット)を考えるところに時間がかかってしまうことがあります。Botを作るという目的を決めることで、先に進みやすくなることもいいところです。

また、

  • 工夫次第でどんな技術でも自由に取り入れられる

という点もあります。

slackは、slack上で発生したユーザーの様々なアクティビティを、自分で作成したWebAPIサーバー(以降、APIサーバー)に送信することができます。つまり、自分で作成したAPIサーバーに届いたあとはどんな処理をすることも可能です。そして、処理の結果をslackに反映させることができます。自前のAPIサーバーで行う処理を拡張することで、いかようにも自分の好きな技術を取り入れることができます。

f:id:joe0000:20181211120401p:plain

その例として、普段はRailsを書いている私ですが、インタラクティブな哲学Botを作ることで下記の技術に触れることができました。

  • Go言語
    • (slackBotのメイン処理記述)
  • クローラ(python)
    • (哲学名言の収集)
  • AWS SAM (LambdaやAPI Gatewayを簡単に構築するためのフレームワーク)
    • AWS Lambda
      • (slackBotの本体処理実行)
    • AWS API Gateway
      • (slackのInteractive機能のWebAPI作成)
    • AWS CloudWatch
      • (Lambda定期実行のイベント)
  • AWS IAM Role
    • (AWS SAMを使うためのユーザー権限付与)
  • AWS DynamoDB
    • (哲学名言を保持するためのストレージ)
  • slackAPI
    • (slackとのやりとり)

こちらが上記を使ってつくった、実際に私が運用している哲学Botです。 1日に1回哲学者の数百種類の名言の中からランダムでつぶやいてくれます。さらに、ポストされた哲学に対してメンバーが名言を評価できるボタンをおいていて、その結果をDynamoDBに記録しています。
(この日の哲学は高評価)

f:id:joe0000:20181211120857p:plain

機能はまだこれだけなのですが、APIサーバーを用意する部分や、DynamoDBからランダムに値を取得して定期的にメッセージをPOSTする部分だけで、色々な技術に触れることができました。

また、今回はAWSのサーバーレスの機能やGo言語を中心に触ってみたかったのでこちらの構成になりましたが、GCPを使っている方だったらGCPを、自前でサーバーを立てて運用してみたい方だったら自前サーバーを、GoではなくNode.jsをやりたい方はNode.jsを、と、柔軟に触ってみたい技術を取り入れることができます。

Bot運用を始めてみる

Bot運用を始めるのにおすすめの手順を書いていきます。

1. やりたいことを決める

まずはどういうBotを作るか決めます。ここが一番楽しい部分と思う方もいるかもしれません。 一方で、ここがどうしても思いつかない方もいると思います。そんな方は、なんでもいいので単純なBotを作ることから初めてみるのがいいと思います。

例えば、毎日決まった時間に「ばなな」と呟くBotを作るとします。これだけでも、上記でいうところの

  • AWS SAM
    • AWS Lambda
    • AWS CloudWatch
  • AWS IAM Role
  • Go言語
  • slackAPI

に触れることができます。
(Lambdaを定期実行にしてslackAPIを叩いてメッセージをPOSTするように作るとこれらが使えます。)

f:id:joe0000:20181211121015p:plain

最初はミニマムではじめても、そこからいくらでも拡張していくことができます。 例えば、これにプラスしてゴリラの画像をクロールしてS3に保存し、決まった時間に画像をPOSTしてくれるようにするだけで「ばなな」をつぶやくだけのBotから、

  • クローラー
  • AWS S3

を使って開発することができます。 さらに、ゴリラの画像のイケテる度合いを5段階で評価できるボタンをつけて、週末には週間イケてるゴリラランキングを出す拡張をするとしたら上記の技術に加えて、例えば下記のような技術をつかえるようになります。

  • AWS API Gateway
  • AWS DynamoDB
  • slackのInteractive機能

さらに、インプットされた顔写真に対して一番近いゴリラの画像を返すようにしたら機械学習を使うこともできますし、処理に時間がかかる場合はAWS SQSなどの非同期処理も導入できるかもしれません。(そこまでしたらすごい)

このように、ミニマムで作ったところから機能を拡張していくことでいかようにも触れる技術を増やすことができるため、最初に作るものを考え過ぎないようにするといいと思います。 最初は「ばなな」と定期的に呟いておかしな人だと思われても、ここまですれば周りの人々が徐々に興味を持ってくれるかもしれません。 なので、なにも思いつかない人は、なんでもいいのでとにかく始めてみてはいかがでしょうか!
(よろしければ「ばなな」のアイデアをお譲りします)

2. slackのアプリを作成する

次に、slackのアプリを登録します。

slackには一般公開のアプリと特定のワークスペースに閉じたアプリを作ることができ、後者であれば手順としても、精神的にも気軽に作成できます。

定期的に「ばなな」と呟くだけのBotであればIncomingWebhooksで十分で、もっと言えばリマインダー機能があれば十分なのですが、今回のような目的の場合拡張性があったほうがよいため、最初からアプリにしておくことをオススメします。

アプリにしておくことで、ボタンをつけたり、slack上で起きた特定のアクションをhookできたり、できることの選択肢がグッと広がります。

アプリを作成する手順は下記です。

1. こちら から CreateNewApp ボタンを押す

2. アプリの名前とアプリを所属させるワークスペースを選択

3. やりたいことに必要な機能の選択

f:id:joe0000:20181211121116p:plain

Incoming Webhooks :

おなじみの機能ですが、特定のチャンネルに対してURLが発行され、URLにPOSTリクエストを送ることによってチャンネルにメッセージをポストする機能です。メッセージの送信しかできません。

curl -X POST -H 'Content-type: application/json' --data '{"text":"Hello, World!"}' https://hooks.slack.com/services/DUMMY/DUMMY/DUMMYDUMMY

slackAPIにもメッセージをチャンネルにPOSTする機能が存在するので、slackAPIを使う場合はこちらの機能はoffで大丈夫です。拡張性を考えると、IncomingWebhookは投稿しかできないのでslackAPIを使うことをおすすめします。

InteractiveComponents:

ボタンやプルダウンメニューなどのインタラクティブな機能を使うためのものなので、自分で用意したAPIサーバーのエンドポイントを登録する必要があります。

ボタンを押したタイミングで指定したエンドポイントにPOSTリクエストを飛ばしてくれます。その内容をみて、あらゆる自由な動作を自分のサーバーで行うことができます。
(アプリを作成した時点でAPIサーバーをまだ作ってなかったのでこの時点ではダミーのURLを設定しました。あとから変えられます。)

ボタンは こんな感じの機能です。

f:id:joe0000:20181211121305g:plain

また、ボタンやプルダウンの他に、Actionを設定してメッセージに対して特定の動作を紐づけることもできます。

f:id:joe0000:20181211121335p:plain

EventSubscriptions:

特定のslack上のアクティビティを検知して、こちらが作成したAPIサーバーにリクエストを送ってくれる仕組みです。なので、InteractiveComponentsと同様に自分で用意したAPIサーバーのエンドポイントを登録する必要があります。

こちらは、アクティビティの結果を指定したAPIサーバーに送信してくれるだけの機能です。アクティビティのPOSTリクエストを受け取ってから、slackにメッセージを送信するなど他のslackのタスクを実行したい場合は slackAPIを使う必要があります。

hookできるslack上のイベントも、様々なものが存在しています。

f:id:joe0000:20181211124456p:plain

Permissions:

こちらの項目では、作成したアプリで、先ほどから連呼しているslackAPIを叩くための権限管理(Scopeの設定)をします。

様々な動作を許可するScopeが存在しているので、適宜必要になるものを選びます。こちらを適切に選択できていないと、やらせたい動作に紐づくslackAPIを叩いた時にScopeエラーがでます。

1つ以上のScopeを設定すると、Scopeの設定と同じ画面にあるアプリをワークスペースにインストールするための Install App to Workspace ボタンがEnableになります。

(1つ追加してボタンがEnableになれば、Scopeは後から追加することもできるのでとりあえず1つScopeを追加しましょう。)

f:id:joe0000:20181211121457p:plain

4. アプリをワークスペースにインストール

3の Permissions の工程でEnableになった Install App to Workspace ボタンを押すと、slackAPIとやりとりする用のOAuthAccessTokenが発行され、晴れてslackAPIが使えるようになります。

f:id:joe0000:20181211121559p:plain

5. 機能実装に必要なToken周りの値を確認する

一番重要なのは下記の2つです。

slackAPIを使う場合

  • OAuth Access Token
    • slackAPIを利用するために使うToken
    • OAuth & Permissionsの項目で参照

InteractiveComponentsやEventSubscriptionsを使う場合

  • Verification Token
    • slackアプリでインタラクティブなアクションが行われた際、自分のslackがAPIサーバーに飛ばすリクエストにくっついてくる認証Token
    • (これがついているリクエスト以外は弾くように実装する)
    • アプリページのBasicInformationにあるAppCredentialsの項目で参照

6. (EventSubscriptionsを使う場合): APIエンドポイント認証処理

EventSubscriptionsを使う場合、設定したAPIサーバーのエンドポイントに対し、slack側が正当性の検証を行います。

こちら に記述してあるやり方で所定の形式のJSONがslackからPOSTされるので、それに対して適した内容をレスポンスすることで検証を完了することができます。

7. 完了!!

以上で、アプリの作成はOKです。 slackAPIを使って好きなことができるようになります!

3. 好きな技術を選択し、機能実装を開始する

どの技術を使うかは、やりたいことによって変化しますし、無数の選択肢が存在しています。 今一番興味がある技術を選んでBot作成のモチベーションをあげてください。

使いたい技術が明確にある方は、Bot運用を開始する手順の1で好きな技術ベースでどのようなBotを作るかを考えるというのも選択肢の一つだと思います。

まとめ

slackBotを運用することで、好きな技術の勉強を楽しく行うことができるようになります。 その最初に、まずは拡張性の高いslackのアプリを作成してみるのはいかがでしょうか!

f:id:joe0000:20181211121702p:plain

↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

次回予告

明日は、delyのWebディレクター/SEO担当のinter_net_ghostによる「kurashiruが取り組むSEOのはなし」です。お楽しみに!

また、dely Advent Calendar 2018の19日目で実際に運用している哲学Botの具体的な実装について紹介します。

dely Advent Calendar 2018 - Qiita

AWSの意図しない料金の上昇に気付く仕組み

はじめに

本記事はSRE 2 Advent Calendar 2018の11日目の記事です。

SRE 2 Advent Calendar 2018 - Qiita

dely Advent Calendar 2018もやっていますので目を通していただけると嬉しいです。クラシルの秘話がたくさん書かれています。

dely Advent Calendar 2018 - Adventar
dely Advent Calendar 2018 - Qiita

こんにちは!delyでSREをやっている井上です。

SREのみなさん!インフラコストの最適化してますか?
delyはどうかというと、正直まだまだ不十分な状況です。。。

クラシルでまだまだやりたいこと・やるべきことがたくさんあり、コスト最適化の優先順位がなかなか上がりにくいのが現状です。

ちなみについ先日クラシルに待望の献立機能がリリースされました!「毎日のメニューを考えるのが大変」という悩みを抱えるSREのみなさん!是非使ってみてください!

prtimes.jp

そんなフェーズのdelyにおいてもインフラコストについて最低限度行っている取り組みがありますので、そちらを紹介しようと思います!

AWSの意図しない料金の上昇に気付く仕組み

delyでは過去にAWSなどの従量課金のサービスの料金が想定以上に増加したことがあり、その対策としてAWSの意図しない料金の上昇に気付くための決まりを作りました。

f:id:gomesuit:20181211104957p:plain:w200

決まりは非常に単純です。

  • 月初めに当月分の料金を見積もる
  • 料金を日々Slackに通知するようにする
  • 上昇に気づいたら決められた内容を調査して報告する

報告内容は

  • 料金の内訳(何の作業でかかっている料金か)
  • 報告時点でかかっている料金
  • かかっている料金がランニングコストなのかイニシャルコストなのか、どちらも含むのか
  • イニシャルコストが合計でいくらかかるか
  • ランニングコストが毎月いくらかかるか

としています。

Slackに通知している内容としては、料金の前日比や対象日時点での見積と実績の比率などです。例えば下記のようなものになります。 f:id:gomesuit:20181210153112p:plain:w400

もちろん上記だけだと、AWSの何の料金が上がっているのか特定することが出来ません。
直前に設定変更を行ったなど自覚があればある程度想定出来るのですが、自覚のないもの(例えば外的要因など)はその都度調査する必要があります。

そのためには上昇した料金に対して、「サービス」毎、「コスト配分タグ」毎、「Usage Type」毎、「API Operation」毎、「リソースID」毎といったようにドリルダウンしていきながら原因を特定できる手段を用意しておく必要があります。

Cost Explorer

AWSにはCost Explorerというコストを表示および分析するためのツールがあります。
(以前は割と使いづらかったのですが、最近UIが改善されて使いやすくなりましたね。)

docs.aws.amazon.com

Cost Explorerを使うことで、特定のサービスに対して「Usage Type」毎や「API Operation」毎や「コスト配分タグ」毎にどれだけ料金がかかっているのか内訳を表示することができます。

ただしCost Explorerでは「リソースID」の指定や「リソースID」毎の集計は出来ません。 そのため例えば下記のような特徴をもったサービスにおいては特定を行うことは難しいのです。

  • Glueなどコスト配分タグ未対応のサービス
  • S3、CloudWatchLogsなどリソースが多くなりがちなサービス

よってリソースIDレベルでの詳細な特定を行うためには、Cost Explorer以外の手段を検討する必要があります。

リソースおよびタグ付きの請求明細レポート

AWSには「リソースおよびタグ付きの請求明細レポート」を出力する機能があります。 「リソースおよびタグ付きの請求明細レポート」はリソースIDとコスト配分タグを含んだ料金明細です。
この料金明細を利用することでリソースID毎の料金を算出することが可能になります。

docs.aws.amazon.com

設定を行うことで任意のS3バケットに定期的に下記のようなオブジェクト名で出力されるようになります。
XXXXXXXXXXX-aws-billing-detailed-line-items-with-resources-and-tags-YYYY-MM.csv

このファイルを直接エディタで開いてリソースID別料金を確認することも理論的には可能ですが、データ量が多すぎるのであまり現実的ではありません。
そのためAthenaを使ってSQLを実行できるようにする必要があります。そのままだとAthenaが認識できないのでGlueのETLジョブ機能を使って前処理を行います。

Glue ETLジョブ

参考に実際に実行しているコードを紹介します。(雑コードですいません)
Glueの実行環境を利用しているだけで変換処理自体は純粋なpysparkで行っています。

import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from pyspark.sql import SQLContext
from awsglue.job import Job
from awsglue.dynamicframe import DynamicFrame
from pyspark.sql.types import *

import boto3
import zipfile
from datetime import datetime, date, timedelta

args = getResolvedOptions(sys.argv, ['JOB_NAME', 'year', 'month'])

date_time = datetime.now() - timedelta(hours=12)

# 定期実行時には実行されたタイミングの年月のレポートを対象に動作するようにしていて、
# パラメータを変更することで任意の年月で実行することも出来るようにしています。
if args['year'] == '9999':
    year = date_time.strftime("%Y")
else:
    year = args['year']

if args['month'] == '99':
    month = date_time.strftime("%m")
else:
    month = args['month']

print('year:' + year)
print('month:' + month)

sc = SparkContext()
glueContext = GlueContext(sc)
job = Job(glueContext)
job.init(args['JOB_NAME'], args)
 
s3 = boto3.resource('s3')

# 出力先のS3バケット名に置き換える
bucket_name = '<リソースおよびタグ付きの請求明細レポートが格納されたS3バケット>'
bucket = s3.Bucket(bucket_name)

# XXXXXXXXXXXを自身のIDに置き換える
csv_name = "XXXXXXXXXXX-aws-billing-detailed-line-items-with-resources-and-tags-%s-%s.csv" % (year, month)
zip_name = csv_name + '.zip'
bucket.download_file(zip_name, 'billing.zip')

# zipファイルのままだとpysparkで処理できないので展開してcsvファイルをアップロードし直しています。
zip_file = zipfile.ZipFile('billing.zip')
filename = zip_file.namelist()[0]
zip_file.extract(filename)
bucket.upload_file(filename, filename)

# glueは使わず純粋なpysparkだけで実行しています。
# RecordTypeがLineItemのものだけを抽出してparquet型で出力しています。
sqlContext =SQLContext(sc)
filename = "s3a://%s/%s" % (bucket_name, csv_name)
df = sqlContext.read.format("com.databricks.spark.csv").option("header", "true").option("inferSchema", "true").load(filename)
df.printSchema()

df2 = df.filter(df.RecordType == 'LineItem').withColumn('InvoiceID', df.InvoiceID.cast(StringType()))
df2.printSchema()

dyf1 = DynamicFrame.fromDF(df2, glueContext, 'dyf1')

prefix = "reports_parquet/year=%s/month=%s" % (year, month)
s3client = boto3.client('s3')

# 前回実行時のparquetファイルを削除しています。
def delete_all_keys(bucket, prefix, dryrun=False):
    contents_count = 0
    next_token = ''
    while True:
        if next_token == '':
            response = s3client.list_objects_v2(Bucket=bucket, Prefix=prefix)
        else:
            response = s3client.list_objects_v2(Bucket=bucket, Prefix=prefix, ContinuationToken=next_token)
        if 'Contents' in response:
            contents = response['Contents']
            contents_count = contents_count + len(contents)
            for content in contents:
                if not dryrun:
                    print("Deleting: s3://" + bucket + "/" + content['Key'])
                    s3client.delete_object(Bucket=bucket, Key=content['Key'])
                else:
                    print("DryRun: s3://" + bucket + "/" + content['Key'])
        if 'NextContinuationToken' in response:
            next_token = response['NextContinuationToken']
        else:
            break
    print(contents_count)

delete_all_keys(bucket_name, prefix)

glueContext.write_dynamic_frame.from_options(
    frame = dyf1,
    connection_type = "s3",
    connection_options = {"path": "s3://%s/reports_parquet/year=%s/month=%s" % (bucket_name, year, month)},
    format = "parquet"
)

job.commit()

リソースIDレベルでの料金の算出

SQLが実行できれば、念願のリソースIDレベルでの料金の算出が出来るようになります。

delyでは可視化ツールにRedashを利用しているのですが、例えば下記のようにS3バケット別の料金も見れるようになっています。 料金上昇の原因もすぐに出来て安心ですね。 f:id:gomesuit:20181211013245p:plain

Redashによる可視化

SQLが実行できるようになればこっちのものですね! あとは可視化ツールで煮るなり焼くなり好きにしましょう。
せっかくなのでdelyでの具体例をいくつか紹介します。モザイクばかりですいません!

下記は特定の月のダッシュボードです。一つ一つはCost Explorerでも表示可能ですが、やっぱりダッシュボードで一括して見れると便利ですね。 f:id:gomesuit:20181210182804p:plain

月とサービスの料金一覧をピボットテーブルで表示しています。 f:id:gomesuit:20181211012700p:plain

下記ではS3の「API Operation」別の料金を表示しています。ピボットテーブルなので更に「Usage Type」毎にドリルダウンすることも出来てアドホックに分析することが可能になっています。 f:id:gomesuit:20181211012926p:plain

さいごに

この記事を書いてる途中でAWSのドキュメントに下記の文章を見つけました。

以下のレポートは利用できなくなります。
代わりに「AWS Cost and Usage Report」を使用することを強くお勧めします。

・・・

調べてみると2018年11月15日から「AWS Cost and Usage Report」の機能を使えばparquet型で出力してくれて、かつ設定用のCloudFormationのテンプレートを出力してくれるようになったようです。

docs.aws.amazon.com

試しに設定してみましたが、とても簡単にSQLを実行できるようになりました。GlueのETLジョブを定期的に実行する必要もないので断然こちらがおすすめです!
過去に遡って出力することは出来ないようなので、まだ設定していない方はとりあえず出力設定だけはやっておいた方が良さそうです。

簡単にはなりますがdelyでのコスト周りの決まりについてご紹介しました。
AWSなど従量課金のサービスのコストは見積もりが難しいですが、コントロール出来るように現状を常に把握しておけると良いですね。

ということで、最後にCost Explorerに個人的に欲しい機能を記載して終わろうと思います。

  • Cost Explorerに個人的に欲しい機能
    • 「テーブル」、「線グラフ」、「棒グラフ」以外のビジュアライズ
    • group byの複数指定
    • ダッシュボードの作成

以上になります。ありがとうございました!

AppleのUI実装をさぐる

こんにちは。delyデザインエンジニアのJohn(@johnny__kei)です。
本記事はdely Advent Calendar 2018の10日目の記事です。

Qiita: https://qiita.com/advent-calendar/2018/dely
Adventar: https://adventar.org/calendars/3535

前日は、プロダクトデザイナーのkassyが「ユーザーの声に振り回されないデザインの改善プロセス」という記事を書きました。いいプロダクトを作るには、ユーザーの声を鵜呑みにするのではなく、きちんと判断する必要がありますよね。

はじめに

みなさんは、iOSアプリ開発をするときに、XcodeのDebugging View Hierarchiesを使用していますか?

Debugging View Hierarchiesを使用すると、アプリが現在の状態で停止され、Viewの階層や、プロパティを確認できます。

https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/debugging_with_xcode/Art/dwx-sw-dvh-1_2x.png 出典: Apple Specialized Debugging Workflows

AutoLayoutが効いていないのを調査するときに、使用したりする方もいらっしゃると思います。

Debugging View HierarchiesはUIKitのUINavigationBarなどのクラスにも適用され、どのようなView階層か見ることができます。
アプリ独自のUIパーツを作成するときに、できるだけ、View構造や、メソッド、プロパティなどを、UIKitにそろえると、使いやすくなると思います。

そこで
前半は、UIKitのいくつかのクラスのView階層について書きます。
後半は、前半をふまえて、サンプルの実装について書きます。

UIKitのView階層

- UINavigationBar

UINavigationBarのsubviewには_UIBarBackground(非公開クラス)があります。
下の画像を見るとわかるように、UIVisualEffectViewやshadowImageが設定されるimageViewなどがあることがわかります。

f:id:JohnnyKei:20181205151301p:plain

UINavigationControllerのnavigationBarでは、StatusBarまでnavigationBarが伸びているように見えるのは、この_UIBarBackgroundがはみ出しているからです。 自分で、UINavigationBarをViewControllerのviewにaddSubviewする場合は、適応されないので、UINavigationControllerの方で、そういった実装がされると推測できます。

imageViewも、Viewから高さ0.5の分だけ下にはみ出ています。これを発見したとき、ビビりました。

f:id:JohnnyKei:20181205151202p:plain

- UIPageControl

横スクロールでページングがあるときに、よく使用されたりします。 UIPageControlはdotのサイズやdot間のマージンは変更できません。

f:id:JohnnyKei:20181205180702p:plain f:id:JohnnyKei:20181205180204p:plain

dot自体は、単なるUIViewであることがわかります。 また、dotは7ptで、dot間のマージンは9ptということがわかりました。
結構シンプルな作りになっています。

また、UIPageControlは、InterfaceBuilderとCodeでの初期状態に違いがあったのも、新しい発見でした。

--- currentPageIndicatorTintColor pageIndicatorTintColor
InterfaceBuilder UIColor.white UIColor.white.withAlphaComponent(0.2)
Code nil nil

サンプル実装

- BottomBar

SafeAreaの登場で、下部に、Viewを配置したいときに、どういう風に実装したらいいか、悩む場合がありますよね。
自分なりに、こうしたらいいんじゃないかという実装を書いていきます。

f:id:JohnnyKei:20181205175842p:plain

static let viewHeight: CGFloat = 49.0のように本来表示したい高さを定義します。 そして、実際にViewControllerのviewにのせるのはこんな感じに制約をつけます。

NSLayoutConstraint.activate([
    bottomBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
    bottomBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    bottomBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -BottomBar.viewHeight),
    bottomBar.bottomAnchor.constraint(equalTo: view.bottomAnchor)
    ])

こうすることで、safeAreaのbottomがある場合は、contentの高さと、safeArea分伸びた状態で表示することができます。

このBottomBarの上に、Viewをのせる場合は、contentViewにのせます。 contentViewは高さは以下のように固定されているので、ボタンはcontentViewに対して、制約をつけることで、いい感じに表示することができるようになります。

contentView.heightAnchor.constraint(equalToConstant: BottomBar.viewHeight)

さらに、前述した、UINavigationBarのshadowImageを表示するimageViewのように、border部分は、はみ出して作ってあります。 これも、contentViewにのせるようにしていて、UINavigationBarのようにしてあります。

contentView.addSubview(topBorder)

topBorder.heightAnchor.constraint(equalToConstant: 0.5)
topBorder.bottomAnchor.constraint(equalTo: contentView.topAnchor)

f:id:JohnnyKei:20181205173613p:plain

- PageControl

前述したように、UIPageControlはdotのサイズやdot間のマージンは定義されていないので、変更することができません。 そこで、UIPageControlとほぼ同じ、プロパティやメソッドを持ち、dotのサイズやdot間のマージンを設定できる、PageControlを実装してみます。

f:id:JohnnyKei:20181205181725p:plain

UIControlは、UIViewのサブクラスなので、普通にViewをのせていくことで大丈夫です。 さらに、UIPageControlのような実装にするには、タップしてPageが変化したときに、UIControl.Event.valueChanged イベントを発火する必要があります。 UIPageControlの説明にも書いてあります。

その実装は以下のようになっています。

override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
        guard let touchPoint = touch?.location(in: self), bounds.contains(touchPoint) else {
            return
        }
       // touchPointが、currentPageのdotの左右どちらかでなどのロジックで判断し、変更があれば発火する
      sendActions(for: .valueChanged)
}

dotのサイズやマージンを定義することで、サイズの計算も簡単になります。

// UIPageControlに合わせる。
var dotSize: CGFloat = 7.0
var dotMargin: CGFloat = 9.0

// pageCountに応じた、size計算
func size(forNumberOfPages pageCount: Int) -> CGSize {
      let height = (15.0 * 2) + dotSize
      guard pageCount > 0 else {
          // UIPageControlがこんな感じの値
          return CGSize(width: dotSize, height: height)
      }
      let width = dotSize * CGFloat(pageCount) + dotMargin * CGFloat(pageCount - 1)
      return CGSize(width: width, height: height)
}

numberOfPages, currentPageなどのプロパティの実装に関しても、結構シンプルになっているので、ぜひサンプルの実装をみてもらえばと思います。
UIPageControlに実装されていることは、全て実装しています。(たぶん)
実装のサンプルは置いておきます。
GitHub Sample Code

まとめ

いかがでしたでしょうか?
Debugging View Hierarchiesを使用すると、AppleのUIKit内部の実装が見れておもしろいですよね。 UI部分の階層構造しかみれませんが、そこから滲み出る、ロジック部分も想像すると、なお面白いと思います。
また、View階層をデザイナーに見せることで、内部構造を理解してもらえるので、次から、そこを考えて、デザインを作ってくれるようになるかもしれませんね。

明日は、サーバーサイドエンジニアのjoeによる「好きな技術を使って作る!くだらないslackBot運用のすヽめ」です。お楽しみに!