一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

一休.comにおけるUI改善の取り組み

こんにちは、宿泊事業本部でサービス開発をしている田中( id:kentana20 )です。

この記事は一休.comアドベントカレンダー2017の4日目です。

今日は弊社が運営しているサービスの1つである一休.comのUI改善に関して

  • どのような体制で開発をしているのか
  • ユーザ体験を向上させるために実施していること

を紹介したいと思います。

UIチームの体制

12/4(月) 現在、一休.comでは

  • PM兼マーケティング: 1名
  • デザイナー: 1名
  • エンジニア: 3名

という体制でUI改善を行っています。

もともとは

  • マーケティング部
  • デザイン部
  • システム開発部

と職種ごとに分かれていた組織でしたが、プロダクト開発をより円滑に、スピーディに行っていくために今春から現在の体制に移行しています。

f:id:kentana20:20171204000610p:plain
開発組織の変遷

このあたりの体制変更については 12/24(日) に予定している id:sisijumi の「開発組織の目的型組織への移行」にてお話があると思いますので、詳細は今回は割愛します。

どのように取り組むタスクを決めているか

大きめの案件については、CEOや事業責任者と議論しながら決めています。

  • 事業の状況
  • 市場環境
  • 競合の取組み

などから、取り組んでいくタスクの大枠が決まります。

一方で、小規模な案件についてはCSやセールスからの改善要望に対して、チームで実施可否と優先順位について意思決定しています。

ユーザ体験向上のために実施していること

プロダクトの改善を精度高く行うために、チームで実施していることをいくつか紹介します。

プロトタイピングとデザインレビュー

今日ではプロトタイピングツールも充実し、企画の段階ではコードを書かずにアイデアをメンバー同士で共有する機会が増えてきていますが、一休でもプロトタイピングを実施して

  • 解決したい課題に対して、適切なUIになっているかをレビューする
  • チームメンバーで完成形に対する認識を揃える

ということを実施しています。

形式としては対面/オフラインでデザインレビューを行い、指摘事項をGitHub Issuesにまとめて議論しながらフィードバックループを回して目指すUIを決めています。

改善するページ/機能によっては、プロトタイピングツールで表現仕切れないケースもあるので、その場合はHTMLモックを作る場合もあります。

誰かが「こういう形で進めよう」と決めたサイクルではないのですが、いまはこのサイクルで改善が回っていて、徐々に精度が出てきているように思います。

リリース前QA

数日で終わるような改善の場合は実施しないこともありますが、チーム内でのQAをリリース前に実施しています。開発中の作業ブランチを デモ用環境 *1 にデプロイしておいて

  • (初回のみ)その改善で実現したいこと、解決したい課題を改めて共有
  • 各自、思い思いにデモ版を触って使用感を確認
  • 改善したほうが良い内容を発表&議論
  • やる/やらないを決めて終了

という流れで実施しています。これは弊社のレストランアプリチームで実施している取組みなのですが、「良さそうだ」と思って宿泊UIチームでも導入しました。

f:id:kentana20:20171204000714p:plain
QAのメモ

機能の規模や重要度によりますが、このQAを2~3回実施してからリリースする、という流れがスタンダードになりつつあります。

  • QA#1: 開発が一通り終わったところで実施
    • プロトタイピング時に決めた仕様が適切か
    • ユーザの課題を解決する改善になっているか
    • 開発した機能にバグ、考慮漏れがないか
  • QA#2: 前回QAでの指摘事項、BugFixを済ませたタイミングで実施
    • 前回QAでの指摘が改善されているか
    • リリースしてよいレベルに仕上がっているか

という形で実施しています。β → RC1 → RC2というイメージですね。

QAに関しては

  • 若干コストをかけすぎでは
  • 早く本番にリリースしてユーザの動きを見たほうが良いのでは

という意見もあり、今後も最適なやり方を模索していきたいと思っています。

ユーザの行動を体験する

一休ではGoogle AnalyticsとBigQueryを使ってユーザの行動ログを分析しています。このログを元に、課題感のあるページや機能に対して実際のユーザがどのような行動をしているかを、実際に体験するということをしています。

例えば、「主要な導線中のとあるページでの離脱が目立つ」という課題があった場合には

  1. 特定ページで離脱しているユーザ1人1人の行動ログを抽出
  2. 抽出した行動ログをもとにユーザの行動を1セッションずつトレースする(実際にサイトを動かして体験する)
  3. それをひたすら繰り返す
  4. ユーザが感じたであろうストレスを考える
  5. ストレスの共通点(離脱の理由)を探す

という流れで解決するべき課題を洗い出します。課題が発見できたら、解決策として改善のアイデアを議論します。

f:id:kentana20:20171204000818p:plain
ユーザの行動ログ

各ユーザのセッションを体験すると

  • 離脱しているユーザの行動を幾つかのグループに分けることで傾向が見えてくる
  • 当初考えていた課題/仮説の裏付けに使える行動を再確認できる

などの効果があり、Google Analyticsのサマリや、KPI/ファネルレポートなどを見るのとは違った課題が見えてくることも少なくありません。

すべての改善で実施しているわけではありませんが、「数字的に課題があるのはわかるけど、何を改善していけばよいかが見えてこない」という場合には有効なアプローチだと考えています。

おわりに

今回は一休.comにおけるUI改善の取り組みについてお話しました。いまのチーム体制になってから約8ヶ月くらいですが、徐々にチームとしての形が出来てきていると思う一方で、改善できる部分はまだまだあるので、社内のほかのチームや社外の事例を参考にUI改善の精度を上げる、開発スピードを上げる取り組みを進めていきたいと思っています。

「こんなやり方をしていて、良い具合です」という事例をご存じの方や知見をお持ちの方はぜひご連絡を! プロダクト開発に関して情報共有しましょう。

一休では、ともに良いサービスをつくっていく仲間(エンジニア/デザイナー/マーケティング)を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください!

明日は @minato128 さんによる「メール配信のモニタリングと障害リカバリーについて」です。お楽しみに!

BeautifulSoup4を使ってスクレイピングしつつ、各メソッドを解説してみる

この記事は一休.comアドベントカレンダー2017の3日目です。

一休.comの開発基盤をやっています akasakas です。

BeautifulSoup4でスクレイピング

スクレイピングでBeautifulSoup4を扱う機会が多いです。 BeautifulSoup4はいろんな便利機能が揃ってますが、自分は全部覚えられないし、使いこなせなません(苦笑)

正直、BeautifulSoup4にあるいくつかのメソッドがそれなりに使えれば、十分スクレイピングできます。

なので、今回はBeautifulSoup4を使い、スクレイピングをして、各種メソッドを紹介します。

スクレイピング対象

一休.com Advent Calendar 2017 - Qiita

やってみること

カレンダーから日付/担当者/タイトルを取得&出力してみます

f:id:akasakas:20171203122132p:plain

結果

day is 1
author is ninjinkun
title is 単純なコードでアプリ内のコンバージョン経路を計測する
----
day is 2
author is ryo511
title is 一休.comのJavaScriptユニットテスト環境
----

...

----
day is 24
author is zimathon
title is 開発組織の目的型組織への移行
----
day is 25
author is ninjinkun
title is 締めます
----

コード

from bs4 import BeautifulSoup
from urllib.request import urlopen

html = urlopen("https://qiita.com/advent-calendar/2017/ikyu")
soup = BeautifulSoup(html, "html.parser")

for advent_calendar_week in soup.tbody:
    for advent_calendar_day in advent_calendar_week.find_all("td", {"class": "adventCalendarCalendar_day"}):
        print(f"day is {advent_calendar_day.p.string}")
        print(f"author is {advent_calendar_day.find_all('div')[0].a.get_text().strip()}")
        print(f"title is {advent_calendar_day.find_all('div')[1].string}") 
        print("----")

1つずつブレイクダウン

下準備:htmlパース

これだけです

from bs4 import BeautifulSoup
from urllib.request import urlopen

html = urlopen("https://qiita.com/advent-calendar/2017/ikyu")
soup = BeautifulSoup(html, "html.parser")

soupオブジェクトにガツっと結果が入ってます。 このオブジェクトから日付・担当者・タイトルをピックアップします。

カレンダーを取得し、ループで回す

for advent_calendar_week in soup.tbody:
    for advent_calendar_day in advent_calendar_week.find_all("td", {"class": "adventCalendarCalendar_day"}):

soup.tbody でtbodyを取得しています。 この中にある <td class="adventCalendarCalendar_day"> が欲しいので、そこから、さらに find_all("td", {"class": "adventCalendarCalendar_day"})<td class="adventCalendarCalendar_day"> を全部ピックアップして、ループで回してます。

日付と担当者とタイトル

        print(f"day is {advent_calendar_day.p.string}")
        print(f"author is {advent_calendar_day.find_all('div')[0].a.get_text().strip()}")
        print(f"title is {advent_calendar_day.find_all('div')[1].string}") 

pタグに日付があるので、 p.string で日付が取得できます divタグの1つ目が担当者、2つ目がタイトルとなります。

個人的に感じたポイント

必要なデータは一括で取得

soup.{tag} で必要なデータは一括で取得できます

find_allで必要な情報だけうまく取得する

divタグの特定クラスをまとめて取得したい場合にfind_allが便利です。

上の例だと

find_all("td", {"class": "adventCalendarCalendar_day"})

で、tdタグの特定クラスだけをまとめて取得しています。


別法

下のリストから日付/担当者/タイトルを取得&出力してみます

f:id:akasakas:20171203122252p:plain

結果

day is 12 / 1
author is ninjinkun
title is 単純なコードでアプリ内のコンバージョン経路を計測する
----
day is 12 / 2
author is ryo511
title is 一休.comのJavaScriptユニットテスト環境
----

...

----
day is 12 / 24
author is zimathon
title is 開発組織の目的型組織への移行
----
day is 12 / 25
author is ninjinkun
title is 締めます
----

コード

from bs4 import BeautifulSoup
from urllib.request import urlopen

html = urlopen("https://qiita.com/advent-calendar/2017/ikyu")
soup = BeautifulSoup(html, "html.parser")

for advent_calendar_day in soup.find_all("div", {"class" : "container"})[4]:
    print(f"day is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_date'}).string}")
    print(f"author is {advent_calendar_day.a.get_text().strip()}")

    if advent_calendar_day.find('div', {'class' : 'adventCalendarItem_entry'}) is None:
        print(f"title is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_comment'}).string}")
    else:
        print(f"title is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_entry'}).get_text()}")
    
    print("----")

1つずつブレイクダウン

リストを取得し、ループで回す

for advent_calendar_day in soup.find_all("div", {"class" : "container"})[4]:

divタグの class="container" の中で、下のリストのオブジェクトを取得し、1行ずつ回しています。

投稿済みと未投稿の分類

以下のように、投稿済みと未投稿でタイトルのDOMが少々異なります。

投稿済み

<div class="adventCalendarItem_commentWrapper">
    <div class="adventCalendarItem_entry">
        <a data-confirm="Are you sure to follow a link to this website?
http://user-first.ikyu.com/entry/singleton-tracking" href="http://user-first.ikyu.com/entry/singleton-tracking" target="_blank">単純なコードでアプリ内のコンバージョン経路を計測する 
            <i class="fa fa-external-link"></i>
        </a>
    </div>
</div>

未投稿

<div class="adventCalendarItem_commentWrapper">
    <div class="adventCalendarItem_comment">BeautifulSoup4を実際に使ってみつつ、各メソッドを解説してみる</div>
</div>

<div class="adventCalendarItem_entry"> の有無で判定し、タイトルを取得しています。

    if advent_calendar_day.find('div', {'class' : 'adventCalendarItem_entry'}) is None:
        print(f"title is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_comment'}).string}")
    else:
        print(f"title is {advent_calendar_day.find('div', {'class' : 'adventCalendarItem_entry'}).get_text()}")

まとめ

BeautifulSoup4を使い、スクレイピングをしてみました。 ここでご紹介した機能はごく一部になりますが、それでも使えれば十分スクレイピングができました。

明日は id:kentana20 さんによる「宿泊サービスにおけるUI改善の取り組み」です。

参考

BeautifulSoup4公式ドキュメント

一休.comのJavaScriptユニットテスト環境

この記事は一休.com Advent Calenrad 2017の2日目です。

宿泊事業本部フロントエンドエンジニアの宇都宮です。

一休.comの宿泊予約サービス(以下、一休)では、以下のようなスタックでWebフロントエンドの開発を行っています。

  • 言語:ES 2017
  • ライブラリ・フレームワーク:古いところはjQuery、新しいところはVue.js
  • ビルドパイプライン:Webpack + Babel

一休では、主要導線のE2Eテストは整備されています*1。一方、フロントエンド(JavaScript)のユニットテストは発展途上といったところです。

本記事では、一休のJSユニットテスト環境の変遷と現状について紹介します。

AVA

2017年4月の時点で、一休のJSユニットテスト環境は以下のような状況でした。

  • テスティングフレームワーク:AVA
  • Babelでビルドされているコード:1,000行程度?
  • テストの数:ほとんどない

AVA は、Babelによるビルド機能を内蔵したテスティングフレームワークです。定番フレームワークMochaに比べて、以下のような特長があります。

  • 暗黙的なグローバルへの依存がない
  • テストが並列に実行される
  • テスト結果の出力がわかりやすい(power-assert)

はじめのうちは、AVAで快適にユニットテストを書いていました。しかし、Babelでビルドされるコードが増えるにつれ、コンパイル時間が問題になり始めました。

一例として、以下のような簡単なJavaScriptコードをテストしてみます。

/**
 * 金額のフォーマットを行う
 * ※Number.prototype.toLocaleString() は古いブラウザでは動かないので正規表現を使っている
 *
 * Usage: money(10000) => '10,000'
 *
 * @param value
 * @returns {string}
 */
export default value => String(value).replace(/([0-9])(?=([0-9]{3})+(?![0-9]))/g, '$1,');

AVAを使うと、テストは以下のように書けます(describeはAVA組み込みの関数ではなく、ava-specライブラリの提供する関数です)。

import { describe } from 'ava-spec';
import money from 'vue/Filters/money';

describe('Money Filter', async (it) => {
  it('金額をフォーマットして返す', async (t) => {
    t.is(money(100), '100');
    t.is(money(1000), '1,000');
    t.is(money(10000), '10,000');
    t.is(money(100000), '100,000');
    t.is(money(1000000), '1,000,000');
  });
});

このテストの実行時間を計測すると、以下のような結果になりました。

$ time npm run ava money.test.js

  √ Money Filter 金額をフォーマットして返す

  1 test passed [12:36:03]


real    0m35.385s
user    0m0.061s
sys     0m0.090s

単純なテストなのに 35秒 もかかっています。実行時間のうち、9割以上を占めるのはコードのコンパイル時間です。AVAは全てのソースコードをビルドしてからテストを実行開始するため、起動が遅くなっています。

AVAのwatchモードを使えばコンパイル時間を減らすことはできますが、起動が遅いという根本的な原因は解決されません。

プリコンパイルによる高速化も検討しましたが、テストの実行手順が複雑化してしまいます。

そもそも、我々がやりたいのはテストを書くことであって、テストの実行環境を最適化することではありません。ということで、AVAからの移行を検討しました。

移行対象の検討

今後Vue.jsを使ったコードが増えることが予想されるため、移行対象の検討に当たっては、Vue.jsとの相性が良いことを念頭に置きました。

参考(1) vue-cli

vue-clivue init webpackすると、以下のスタックでユニットテスト環境が構築されます。

Karmaはブラウザを使ったテストランナーですが、ヘッドレスブラウザのPhantomJSを使うことでCI環境でも実行できるようにしています。

※以前検討を行った時点では、Karma + Mochaのみでしたが、最新のvue-cliでは「Jest」「Karma and Mocha」から選べるようになっています。

参考(2) vue-test-utilsのexample

公式のテストユーティリティvue-test-utilsでは、以下のテスティングフレームワークを使用したexampleを提供しています。

それぞれの実行時間を計測してみました。各exampleで全く同じテストを実行しているわけではないですが、参考にはなると思います。

計測結果は下記で、tapeが最速、AVAは他より倍くらい遅い、ということがわかります。

Runner Time
Jest 10.584s
Mocha 8.008s
tape 6.311s
AVA 21.106s

tapeが最速で、AVAが群を抜いて遅い、という傾向は、vue-unit-test-perf-comparisonとも一致します。

Runner 10 tests 100 tests 1000 tests 5000 tests
tape 2.32s 3.49s 9.28s 38.31s
jest 2.44s 4.50s 21.84s 91.91s
mocha-webpack 2.32s 3.07s 10.79s 38.97s
karma-mocha 7.93s 11.01s 33.30s 119.34s
ava 19.05s 73.44s 625.15s 7161.49s

移行対象の決定

移行対象は、下記のスタックにしました。

Mochaにしたのは、vueコミュニティでは最も広く使われている(vue-cliが使っている)ためです。

Karmaは一応導入しましたが、あまり使っていません。browser-envを使えば、DOMを使っているコードのテストをNode.js上でも実行できるからです。また、Karmaはブラウザの起動コストの分、実行が遅くなるのも懸念点です。

どのくらい改善するか計測する

AVAで35秒かかっていたテストが、どれくらいの実行時間になるか計測してみます。

$ time npm run mocha money.spec.js

 WEBPACK  Compiling...

 WEBPACK  Compiled successfully in 963ms

 MOCHA  Testing...


  Money Filter
    √ 金額をフォーマットして返す


  1 passing (3ms)

 MOCHA  Tests completed successfully


real    0m5.217s
user    0m0.000s
sys     0m0.105s

35秒 => 5秒と、大幅に改善していることがわかります! mocha-webpackを使うことで、テストの実行に必要なファイルだけをビルドしているのが速度の改善に寄与しています。

Mocha移行後のテストコード

さきほどのmoney関数のテストを、Mocha + assertで書くと以下のようになります。

/* global describe, it */
import assert from 'assert';
import money from '@js/vue/Filters/money';

describe('Money Filter', () => {
  it('金額をフォーマットして返す', () => {
    assert.equal(money(100), '100');
    assert.equal(money(1000), '1,000');
    assert.equal(money(10000), '10,000');
    assert.equal(money(100000), '100,000');
    assert.equal(money(1000000), '1,000,000');
  });
});

見ての通り、describe~itという基本的な構造は変わりません。大きな違いは、describeit というグローバル関数が定義されていることが前提になっている点です。この点だけ見るとAVAの方が良いのですが、テストコードの美しさのために開発効率を犠牲にすることはできません…。

今後の展望

ユニットテスト環境の構築については一段落したので、テストケースの増加やカバレッジレポートの定点観測といった、テストの網羅性を高める取り組みを進めていきたいと考えています。

明日はakasakasさんによる「BeautifulSoap4を使ってスクレイピングしつつ、各メソッドを解説してみる」です。

単純なコードでアプリ内のコンバージョン経路を計測する

この記事は一休.comアドベントカレンダー2017の1日目です。

一休レストランiOSアプリを開発しているid:ninjinkunです。

アプリを改善する計画を立てる際に、どこから手を着けたら良いか優先順位を決める必要があります。Eコマースなどコンバージョン(以下CV)がはっきり定義できるアプリの場合には、実際のCVに繋がっている画面とフローを改善するのが近道でしょう。

しかしCVに繋がる経路がどこなのか調べるためには、依存関係があるイベントを計測する必要があり、これは意外と面倒です。導入しているGoogle Analyticsの目標到達プロセスやゴールフロー機能を使えばできるような気がするのですが、設定が複雑かつ結果が出るのに時間がかかるので、残念ながら自分は使いこなせたことがありません*1

f:id:ninjinkun:20171130201906p:plain

計測

しかしプログラマの目で見ると、予約前に表示された画面と予約完了画面を結びつけるのはそんなに難しくなさそうに見えます。そこでとても単純な方法ではありますが、直接的にアプリ内のCV経路を計測するために、アプリに直接トラッキングのためのコードを埋め込みました。

enum CVRoute {
    case search, favorite, reserveHistory, message
}

// 単純なコードというのはそう、シングルトンです
struct CVRouteManager {
      static let sharedManager = CVRouteManager()
      var lastRoute: CVRoute?
}

このシングルトンにそれぞれの画面を表示した際に経路を記録し

class ReserveHistoryViewController: UIViewController {
    func viewDidAppear(animated: Bool) {
        ...
        CVRouteManager.sharedManager.lastRoute = . reserveHistory
        ...
    }
}

予約が行われた際にGoogle Analyticsのカスタムディメンジョンに載せて送信します。

class ReserveCompleteViewController: UIViewController {
    static let LastRouteDimension = 20

    func viewDidAppear(animated: Bool) {
        ...
        // 実際はトラッキング用のクラスに分離しているが、イメージはこんな感じ
        let tracker = GAI.sharedInstance().defaultTracker
       ...
        if let lastRoute = CVRouteManager.sharedManager.lastRoute {
            tracker.set(GAIFields.customDimension(for: LastRouteDimension), value: lastRoute)
        }
        ...
        traker.send(...)
    }
}

結果

製品にこのコードを埋め込んで一週間ほど動かしてみたところ、一休レストランのiOSアプリではお気に入りからのCVが検索に次いで多いという結果が得られました。

このデータとユーザーインタビューを元に、アプリを使ってお店を探すユーザーの行動をイメージしてみると、

  1. お店を決めるためにまず検索
  2. 気になったお店をお気に入りに登録
  3. 1~2を繰り返す
  4. お気に入りに登録した中から、一緒に行く友だちにリンクを共有して相談
  5. お店が決まったら、お気に入りから予約←これが計測したCV前画面

というように、お気に入りを使いながらお店を決めるストーリーを想像することができます。そして最終的にはCV数の多さが決め手になり、優先度を上げてこのストーリーを検証、強化するという意志決定を行うことができました。

おわりに

以上、単純なコードで手軽に経路計測を行う方法でした。Google AnalyticsやFirebase等の計測サービスでもっと簡単にできる方法をご存知でしたら、ぜひ教えてください。

最後に唐突に宣伝ですが、クリスマスや忘年会のご予約に、一休レストランアプリをぜひご利用ください。

明日は @ryo511 さんによる「Vue.jsのユニットテストについて」です。

*1:アプリの場合はWebと違い、タブやモーダルビューの存在により、複数の画面を並行に操作して、最後は購入などのCVに到達するということが起こります。このため、トラッキングサービスが出してくるイベントの前後関係を素直に信じられないという問題もあります