Core Text と遊んでみましょう

こんにちは、技術部モバイル基盤グループのヴァンサン(@vincentisambart)です。

この間、クックパッドの iOS アプリの開発で Core Text と色々遊んだので、今日は Core Text の話をしましょう。

課題は表示する文字の一部の裏に角丸長方形を表示することです。例えばクックパッド iOS アプリに表示されているリンクを長く押すと表示されている角丸長方形です。以下の画像は「落し蓋」に表示されるタッチフィードバックが見えます。区域を計算したら、その後タップ区域のためにも使えますしね。

f:id:vincentisambart:20160617074525p:plain

以下に説明するやり方はクックパッド iOS アプリのやり方を簡略化したものです。(クックパッド iOS アプリは実装時にまだ Swift を使い始めていなかったので Objective-C ですけども)

Swift Playground (Swift 2.2) で開発しましょう。コードは iOS 用ですが、少しいじれば OS X でも使えるはずです。スターティングポイントは下記の新しい画像に文字列を表示するコードです。

import UIKit
import CoreText

let availableWidth: CGFloat = 200 // 文字表示に使える幅
let text = "français 日本語 हिन्दी English\n\n言語混ぜるのっておもしろくない?"
let font = UIFont.systemFontOfSize(20)
// kCTFontAttributeNameの代わりにNSFontAttributeNameも使えるけど、CocoaとCore Textのattributesは違いあるので、間違いを防ぐためにCore Textのを使いましょう。
let attributes = [
    kCTFontAttributeName as String: font,
    // ユーザーの言語が何であろうと、漢字は日本語フォントを優先に使ってほしいですね。
    kCTLanguageAttributeName as String: "ja",
]
// Core Textの説明書がCFAttributedStringを使うけど、NSAttributedStringとCFAttributedStringがtoll-free bridgedなので、もっと使いやすいNSAttributedStringを使います。
let attributedString = NSAttributedString(string: text, attributes: attributes)
// Core Textのframesetterというのは文字列を指定される形の中にレイアウトするツールです。
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
// 表示に必要な高さを計算します。
let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, CGSize(width: availableWidth, height: CGFloat.max), nil)
let bounds = CGRect(origin: CGPoint.zero, size: frameSize)
// 文字列を長方形の中に表示したいだけですね。
let path = CGPathCreateWithRect(bounds, nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)

// 新しい画像を描き始めます。
UIGraphicsBeginImageContextWithOptions(frameSize, true, 2.0)
if let context = UIGraphicsGetCurrentContext() {
    // 背景を白で塗ります。
    CGContextSetRGBFillColor(context, 1.0, 1.0, 1.0, 1.0)
    CGContextFillRect(context, bounds)

    // 何もしないとCore Textの表示が逆さまなので正しい方向に表示されるようにします。
    CGContextTranslateCTM(context, 0, frameSize.height)
    CGContextScaleCTM(context, 1.0, -1.0)

    // 文字の表示です。
    CTFrameDraw(frame, context)
}
// 描いた画像を出します。
let image = UIGraphicsGetImageFromCurrentImageContext()
// 描き終えます。
UIGraphicsEndImageContext()

Playground上で画像の表示をインラインに表示できて便利です。コードが実行されたら画像が定義されている場所の右側に画像のサイズが表示されていて、マウスカーソルをその上に乗せたら右の一番端に表示されるボタンをクリックしたら画像がその下に表示されます。

f:id:vincentisambart:20160617074532p:plain

framesetter には色々な設定がありますが、それでも足りない人は CTTypesetterCreateWithAttributedStringCTTypesetterSuggestLineBreakCTTypesetterCreateLine で自分で行の列を作れます。

Core Foundation

開発を続けるには Core Text が返している Core Foundation の型(CFArrayCFRange)を扱わなければいけないけど、Swift だと少し扱いづらいですね。もっと扱いやすくしましょう。

まず、 CFRange はシンプルな struct なので、そんなに扱いづらいわけではないけど、便利関数が少ないですね。 NSRange はそういう便利機能が色々あるので、簡単に変換できるようにしましょう。

extension NSRange {
    // CFRangeからNSRangeを作れるようにします。
    init(_ range: CFRange) {
        self.init(location: range.location, length: range.length)
    }
}

extension CFRange {
    // NSMaxRangeのCFRange版です。
    var max: CFIndex {
        return location + length
    }
}

折角 NSRange をいじっているなら、あとで使えそうな便利機能を1個追加しておきましょう。

// 渡されるrangeが重なるかどうか。
func rangesOverlap(range1: NSRange, _ range2: NSRange) -> Bool {
    return NSIntersectionRange(range1, range2).length != 0
}

CFRange は簡単でしたが、 CFArray を Swift の配列に変換するのが少しややこしいです。一番自然なやり方でやってみましょう。

let lines = CTFrameGetLines(frame) as! [CTLine]

上記のコードを実行するとクラッシュします。実は Swift の配列に変換する前に NSArray に変換すると動きます。

let lines = (CTFrameGetLines(frame) as NSArray) as! [CTLine]

じゃあそれを関数にしてみましょう。

func toArray<T>(sourceArray: CFArray) -> [T] {
    return (sourceArray as NSArray) as! [T]
}

上記のコードは動きますが、Xcode 7.3では、間違っている警告「Cast from 'NSArray' to unrelated type '[T]' always fails」が出ます。そしてなぜか関数の中だと as NSArray がなくても動きます(警告まだ出ますけど)。

警告や謎キャストが好きじゃないので。代わりに CFArray の中身を新しい Swift 配列に入れるようにしましょう。

func toArray<T>(sourceArray: CFArray) -> [T] {
    var destinationArray = [T]()
    let count = CFArrayGetCount(sourceArray)
    destinationArray.reserveCapacity(count)
    for index in 0..<count {
        let untypedValue = CFArrayGetValueAtIndex(sourceArray, index)
        let value = unsafeBitCast(untypedValue, T.self) // 😅
        destinationArray.append(value)
    }
    return destinationArray
}

結局また乱暴なキャストが必要になりましたね…でも仕方ない気がします。

各文字がどこに表示されるのか

CTFrame には、表示されるグリフの位置が決まっています。ある座標がどの文字に一番近いのか教えてくれる関数(CTLineGetStringIndexForPosition)、または文字列のあるインデックスのy座標を教えてくれる関数(CTLineGetOffsetForStringIndex)があります。けれど、文字列の各文字がとっているスペースを直接教えてくれる関数がありません。英語、日本語、だけを対象にするなら CTLineGetOffsetForStringIndex で簡単にできそうですが、もっと複雑な言語にも対応したければそんなに簡単にいきません。簡単じゃないとはいえ、そこまで複雑でもありません。

詳しい説明は公式Core Text紹介でご覧になれますが、軽く説明しましょう。CTFrame に何が入っているのかといいますと、単に CTLine(行) のリストです。「行」というのは "\n" で句切らているパラグラフではなく、表示の1行1行です。

そして各行が CTRun で作られています。"run" がフォント、フォントサイズ、方向、が同じなグリフのリストです。気をつけるべきなのは、元々指定されたフォントが同じでも、文字がフォントに存在していなければ、代わりに別のフォントが使われるので、別の"run"になるかもしれません。例えば、文字列が"Vincentと申します"とフォントがシステムフォント(San Francisco)の場合、2つのrunになります:

  • 「Vincent」のグリフが入ったrun (フォント:San Francisco)
  • 「と申します」のグリフが入ったrun (フォント:Hiragino Kaku)

求める長方形を探すには、各グリフを見て、そのグリフは選択されている文字から来ているのかを確認しましょう。

しかし、グリフがどの文字から来ているのか教えてくれる CTRunGetStringIndices という関数が少し使いづらいです。グリフごとに文字列の中の開始インデックスだけを返します。1つのグリフが複数の文字を使うことがあります。例えば合字(リガチャー)や結合文字列(combining character sequence)ですね。なので開始インデックスだけではなく、文字列の区域が必要ですね。

次のグリフの開始インデックスに1を引けば…と思った方いるかもしれませんが、残念ながらそんなにうまく行きません。アラビア語やヘブライ語だと 3, 2, 1, 0 になりますし、次のヒンディー語の文字列「हिन्दी」のCTRunGetStringIndicesの結果が:1, 0, 2, 5。並び順が予測できません。

では、各文字で開始インデックスに一番近いインデックスを探すコードを書きましょう。

func stringRangesPerGlyph(run: CTRun) -> [NSRange] {
    let runEndIndex = CTRunGetStringRange(run).max
    let glyphCount = CTRunGetGlyphCount(run)

    var stringIndices = Array<CFIndex>(count: glyphCount, repeatedValue: 0)
    CTRunGetStringIndices(run, CFRange(location: 0, length: glyphCount), &stringIndices)

    return stringIndices.map { glyphStartIndex in
        // glyphStartIndexより大きいけど一番近いインデックスを探します。
        var glyphEndIndex = runEndIndex
        for comparedIndex in stringIndices {
            if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
                glyphEndIndex = comparedIndex
            }
        }
        // 因みに let glyphEndIndex = stringIndices.filter({ $0 > glyphStartIndex }).sort().first ?? runEndIndex でもいけます。

        return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
    }
}

実装

必要な物が揃ったので実装してみましょう。最初に書いたコードの上に上記のextensionや関数を入れて、 let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil) の下に以下のコードを入れましょう。(全部が入ったコードはこの記事の一番下にも入れておきます。)

// 2つのCGRectが水平にすぐ隣なのかどうか。
func rectAdjacentHorizontally(rect1: CGRect, _ rect2: CGRect) -> Bool {
    if rect1.intersects(rect2) {
        return true
    }
    if abs(rect1.maxX - rect2.minX) <= 1.0 {
        return true
    }
    if abs(rect1.minX - rect2.maxX) <= 1.0 {
        return true
    }
    return false
}

let roundedRectangleRanges = [
    NSRange(location: 10, length: 5),
    NSRange(location: 31, length: 3),
]

let lines: [CTLine] = toArray(CTFrameGetLines(frame))
var lineOrigins = Array<CGPoint>(count: lines.count, repeatedValue: CGPoint.zero)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &lineOrigins)

var rectsFound = [CGRect]()

for (lineIndex, line) in lines.enumerate() {
    let lineRange = NSRange(CTLineGetStringRange(line))
    let runs: [CTRun] = toArray(CTLineGetGlyphRuns(line))

    var ascent: CGFloat = 0
    var descent: CGFloat = 0
    CTLineGetTypographicBounds(line, &ascent, &descent, nil)
    let lineOrigin = lineOrigins[lineIndex]
    let lineMinY = lineOrigin.y - descent
    let lineMaxY = lineOrigin.y + ascent

    for roundedRectangleRange in roundedRectangleRanges {
        if !rangesOverlap(roundedRectangleRange, lineRange) {
            continue // この長方形がこの行に入りません。
        }

        var rectsForRangeOnCurrentLine = [CGRect]()

        for run in runs {
            let runRange = NSRange(CTRunGetStringRange(run))
            if !rangesOverlap(roundedRectangleRange, runRange) {
                continue // この長方形がこのrunに入りません。
            }

            let glyphCount = CTRunGetGlyphCount(run)
            let allGlyphs = CFRange(location: 0, length: glyphCount)
            let stringRanges = stringRangesPerGlyph(run)
            var positions = Array<CGPoint>(count: glyphCount, repeatedValue: CGPoint.zero)
            CTRunGetPositions(run, allGlyphs, &positions)
            var advances = Array<CGSize>(count: glyphCount, repeatedValue: CGSize.zero)
            CTRunGetAdvances(run, allGlyphs, &advances)

            // 以前に説明したとおり、グリフが並び順通りじゃないから、まずはグリフ1つずつ長方形を作ります。
            for (glyphIndex, stringRange) in stringRanges.enumerate() {
                if !rangesOverlap(roundedRectangleRange, stringRange) {
                    continue
                }
                let x1 = positions[glyphIndex].x
                let x2 = positions[glyphIndex].x + advances[glyphIndex].width
                let minX = min(x1, x2)
                let maxX = max(x1, x2)
                let rect = CGRect(x: minX, y: lineMinY, width: maxX - minX, height: lineMaxY - lineMinY)
                rectsForRangeOnCurrentLine.append(rect)
            }
        }

        // このrangeのすぐ隣の長方形をくっつけます。
        rectsForRangeOnCurrentLine.sortInPlace { $0.minX < $1.minX }
        var mergedRects = [CGRect]()
        for rect in rectsForRangeOnCurrentLine {
            var rectToAppend = rect
            if let previousRect = mergedRects.last {
                if rectAdjacentHorizontally(previousRect, rect) {
                    mergedRects.removeLast()
                    let minX = min(previousRect.minX, rect.minX)
                    let maxX = max(previousRect.maxX, rect.maxX)
                    rectToAppend.origin.x = minX
                    rectToAppend.size.width = maxX - minX
                }
            }
            mergedRects.append(rectToAppend)
        }
        rectsFound.appendContentsOf(mergedRects)
    }
}

表示は CTFrameDrawの上に以下のコードを入れましょう。

UIColor.cyanColor().setFill()
for zone in zonesFound {
    let roundedRectanglePath = UIBezierPath(roundedRect: zone, cornerRadius: 4)
    roundedRectanglePath.fill()
}

f:id:vincentisambart:20160617074536p:plain

最後に

一番重要な処理はやってありますけど、改善の余地はありますね。

まず、制限が1つあります:roundedRectangleRanges が合字や結合文字列の真ん中で始まる/終わる場合、グリフ全体の後ろに角丸長方形を描くことになります。それが問題になるかどうかは使う場所次第ですね。

あと、一番上のスクリーンショットみたいに、複数行に渡る時は行末や行頭では角丸にしない方がいいかもしれません。クックパッド iOS アプリでやっていて、そこまで難しくないけど記事が既に十分長いです(笑)。

全コードまとめ

import UIKit
import CoreText

extension NSRange {
    // CFRangeからNSRangeを作れるようにします。
    init(_ range: CFRange) {
        self.init(location: range.location, length: range.length)
    }
}

extension CFRange {
    // NSMaxRangeのCFRange版です。
    var max: CFIndex {
        return location + length
    }
}

// 渡されるrangeが重なるかどうか。
func rangesOverlap(range1: NSRange, _ range2: NSRange) -> Bool {
    return NSIntersectionRange(range1, range2).length != 0
}

func toArray<T>(sourceArray: CFArray) -> [T] {
    var destinationArray = [T]()
    let count = CFArrayGetCount(sourceArray)
    destinationArray.reserveCapacity(count)
    for index in 0..<count {
        let untypedValue = CFArrayGetValueAtIndex(sourceArray, index)
        let value = unsafeBitCast(untypedValue, T.self) // 😅
        destinationArray.append(value)
    }
    return destinationArray
}

func stringRangesPerGlyph(run: CTRun) -> [NSRange] {
    let runEndIndex = CTRunGetStringRange(run).max
    let glyphCount = CTRunGetGlyphCount(run)

    var stringIndices = Array<CFIndex>(count: glyphCount, repeatedValue: 0)
    CTRunGetStringIndices(run, CFRange(location: 0, length: glyphCount), &stringIndices)

    return stringIndices.map { glyphStartIndex in
        // glyphStartIndexより大きいけど一番近いインデックスを探します。
        var glyphEndIndex = runEndIndex
        for comparedIndex in stringIndices {
            if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
                glyphEndIndex = comparedIndex
            }
        }
        // 因みに let glyphEndIndex = stringIndices.filter({ $0 > glyphStartIndex }).sort().first ?? runEndIndex でもいけます。

        return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
    }
}

// 2つのCGRectが水平にすぐ隣なのかどうか。
func rectAdjacentHorizontally(rect1: CGRect, _ rect2: CGRect) -> Bool {
    if rect1.intersects(rect2) {
        return true
    }
    if abs(rect1.maxX - rect2.minX) <= 1.0 {
        return true
    }
    if abs(rect1.minX - rect2.maxX) <= 1.0 {
        return true
    }
    return false
}


let availableWidth: CGFloat = 200 // 文字表示に使える幅
let text = "français 日本語 हिन्दी English\n\n言語混ぜるのっておもしろくない?"
let font = UIFont.systemFontOfSize(20)
// kCTFontAttributeNameの代わりにNSFontAttributeNameも使えるけど、CocoaとCore Textのattributesは違いあるので、間違いを防ぐためにCore Textのを使いましょう。
let attributes = [
    kCTFontAttributeName as String: font,
    // ユーザーの言語が何であろうと、漢字は日本語フォントを優先に使ってほしいですね。
    kCTLanguageAttributeName as String: "ja",
]
// Core Textの説明書がCFAttributedStringを使うけど、NSAttributedStringとCFAttributedStringがtoll-free bridgedなので、もっと使いやすいNSAttributedStringを使います。
let attributedString = NSAttributedString(string: text, attributes: attributes)
// Core Textのframesetterというのは文字列を指定される形の中にレイアウトするツールです。
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
// 表示に必要な高さを計算します。
let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, CGSize(width: availableWidth, height: CGFloat.max), nil)
let bounds = CGRect(origin: CGPoint.zero, size: frameSize)
// 文字列を長方形の中に表示したいだけですね。
let path = CGPathCreateWithRect(bounds, nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)

let roundedRectangleRanges = [
    NSRange(location: 10, length: 5),
    NSRange(location: 31, length: 3),
]

let lines: [CTLine] = toArray(CTFrameGetLines(frame))
var lineOrigins = Array<CGPoint>(count: lines.count, repeatedValue: CGPoint.zero)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &lineOrigins)

var rectsFound = [CGRect]()

for (lineIndex, line) in lines.enumerate() {
    let lineRange = NSRange(CTLineGetStringRange(line))
    let runs: [CTRun] = toArray(CTLineGetGlyphRuns(line))

    var ascent: CGFloat = 0
    var descent: CGFloat = 0
    CTLineGetTypographicBounds(line, &ascent, &descent, nil)
    let lineOrigin = lineOrigins[lineIndex]
    let lineMinY = lineOrigin.y - descent
    let lineMaxY = lineOrigin.y + ascent

    for roundedRectangleRange in roundedRectangleRanges {
        if !rangesOverlap(roundedRectangleRange, lineRange) {
            continue // この長方形がこの行に入りません。
        }

        var rectsForRangeOnCurrentLine = [CGRect]()

        for run in runs {
            let runRange = NSRange(CTRunGetStringRange(run))
            if !rangesOverlap(roundedRectangleRange, runRange) {
                continue // この長方形がこのrunに入りません。
            }

            let glyphCount = CTRunGetGlyphCount(run)
            let allGlyphs = CFRange(location: 0, length: glyphCount)
            let stringRanges = stringRangesPerGlyph(run)
            var positions = Array<CGPoint>(count: glyphCount, repeatedValue: CGPoint.zero)
            CTRunGetPositions(run, allGlyphs, &positions)
            var advances = Array<CGSize>(count: glyphCount, repeatedValue: CGSize.zero)
            CTRunGetAdvances(run, allGlyphs, &advances)

            // 以前に説明したとおり、グリフが並び順通りじゃないから、まずはグリフ1つずつ長方形を作ります。
            for (glyphIndex, stringRange) in stringRanges.enumerate() {
                if !rangesOverlap(roundedRectangleRange, stringRange) {
                    continue
                }
                let x1 = positions[glyphIndex].x
                let x2 = positions[glyphIndex].x + advances[glyphIndex].width
                let minX = min(x1, x2)
                let maxX = max(x1, x2)
                let rect = CGRect(x: minX, y: lineMinY, width: maxX - minX, height: lineMaxY - lineMinY)
                rectsForRangeOnCurrentLine.append(rect)
            }
        }

        // このrangeのすぐ隣の長方形をくっつけます。
        rectsForRangeOnCurrentLine.sortInPlace { $0.minX < $1.minX }
        var mergedRects = [CGRect]()
        for rect in rectsForRangeOnCurrentLine {
            var rectToAppend = rect
            if let previousRect = mergedRects.last {
                if rectAdjacentHorizontally(previousRect, rect) {
                    mergedRects.removeLast()
                    let minX = min(previousRect.minX, rect.minX)
                    let maxX = max(previousRect.maxX, rect.maxX)
                    rectToAppend.origin.x = minX
                    rectToAppend.size.width = maxX - minX
                }
            }
            mergedRects.append(rectToAppend)
        }
        rectsFound.appendContentsOf(mergedRects)
    }
}


// 新しい画像を描き始めます。
UIGraphicsBeginImageContextWithOptions(frameSize, true, 2.0)
if let context = UIGraphicsGetCurrentContext() {
    // 背景を白で塗ります。
    CGContextSetRGBFillColor(context, 1.0, 1.0, 1.0, 1.0)
    CGContextFillRect(context, bounds)

    // 何もしないとCore Textの表示が逆さまなので正しい方向に表示されるようにします。
    CGContextTranslateCTM(context, 0, frameSize.height)
    CGContextScaleCTM(context, 1.0, -1.0)

    UIColor.cyanColor().setFill()
    for rect in rectsFound {
        let roundedRectanglePath = UIBezierPath(roundedRect: rect, cornerRadius: 4)
        roundedRectanglePath.fill()
    }

    // 文字の表示です。
    CTFrameDraw(frame, context)
}
// 描いた画像を出します。
let image = UIGraphicsGetImageFromCurrentImageContext()
// 描き終えます。
UIGraphicsEndImageContext()

開発コストを最小限にして施策を進める

投稿推進部・ディレクターの中山です。
普段ディレクターはエンジニアとペアを組んでサービス開発をすることが多いですが、エンジニアが別の開発に集中したい時は、ディレクターだけで施策を進めることもあります。エンジニアがいないと動くものができない…と言っていては何もできません。
既に多くのディレクターの方が試していることかもしれませんが、実装に入る前に紙レベルでモノを作ってテストしたり、一般に出回っているツールを活用してみたり…と方法は色々とあります。私がここ数ヶ月で実践してきたことを、おさらいも兼ねてご紹介したいと思います。

方法1:紙でイメージを膨らませる

サービスを考える際、いきなり実装に入ることはまず無いと思います。
当たり前の話かもしれませんが、スピーディにイメージを掴むには手書きが便利。何度も書いたり直したりしつつ頭の中のイメージを具体化し、周囲のスタッフに当ててみることがができます。 f:id:akoakon777:20160614182920p:plain

例えば上記の写真の例は、見やすく書きやすいレシピフォーマットについて考えて書いたもの。普通のノートやレシピ用に売られているカードなどもあり、これらに実際に書いてみたり、スマホサイズに切り取ってみたりして使用感を試します。
右は手書きではありませんが、自分のレシピをカード状に置き直して紙に印刷してみたもの。普通のA4の紙に印刷し余白を折って整えただけのものですが、手に取ってみることで「文字はもうちょっと減らしたいな」「写真サイズはこれくらいが良いな」などの実感を得ることができます。

方法2:一般に出回っているツールを活用

プロトタイプツール

コードが書けなくてもFlintoやProttなどのツールを使えば動きのあるプロトタイプを作れる良い時代です。手書きや紙のコンテンツでイメージが出来たらこれらのツールで動くものを作り、スマホの実機でユーザーテストをすることができます。

ここでちょっと気をつけたいのは、プロトタイプツールが便利なだけに、ついつい作り込み過ぎてしまうことです。細かいデザインや動きは実装段階でデザイナーやエンジニアにお任せすれば良いので、ここではあくまでもユーザーが画面を見た際に、どういう意図をもってどんな動きをしようとしているのか を確かめます。
ユーザーのどんな課題をどうやって解決するツールなのか
を明確にしておき、その意図のようにユーザーが動いているか(或いはどこで躓いているのか)を確認する目的で使います。

SNSを活用

自分でプロトタイプを作らずとも、既に人が集まっていて尚且つ投稿機能を備えた場を借りることもできます。
TwitterやInstagramのようなSNSは非常に便利。
例えば「写真+1行のキャプション」のようなひと纏まりのコンテンツが周囲の人の興味をひけるのか、を試したい場合。そのまま自分で「写真+1行のキャプション」を作成し、コンテンツとしてSNSに投下してみると、「いいね!」やコメントの数で手応えを掴むことができます。あくまでも参考程度ではありますが、ざっくりとした反響を掴むことで、プロダクトの方向性が正しいのかを確かめることが可能です。

サービスの作りそのものを参考にできるだけでなく、こうしたちょっとしたテストにも活用できるので、自社以外の人気のサービスを普段から自分で使い込んでおくことは非常に有益だと思います。

方法3:既存の社内ツールをうまく使う

新しい施策を実施する際、目的に合わせて新たな機能を開発したくなってしまいますが、エンジニアのリソースは限られています。新しく作る前に、既にある仕組みを活用できないか考えてみると良いでしょう。
例えば弊社の場合は、ユーザー向けの汎用的なアンケートツールや連絡先の取得ツールが既にありました。社内で専用のものを内製していない場合でも、一般的に使えるアンケートやメールのような仕組みは色々あるのではないでしょうか。 本当に開発が必要なのか、手持ちのツールを組み合わせて解決できる可能性をまずは考えてみると開発の手間を省けるというのはよくあります。

次に、ここまでにご紹介した手法を組み合わせて今年の春に実施した「母の日のフォトブック企画」の事例をご紹介します。

「母の日のフォトブック企画」

考えた企画は、
ユーザーさんが「母の日」というイベントを前に、お母さんの思い出の味をレシピにしてをクックパッドにのせる
→自分はいつでも料理を再現できて便利になり、お母さんには記念のフォトブックとともに感謝の気持ちを届けることができる
  というもの。

手作りの試作品でテスト

まず、本当にこの企画がユーザーの心に響くのか、をテストするため、私自身がクックパッドにのせている母親のレシピを紙に印刷し、手作りのフォトブックを作成。 f:id:akoakon777:20160614183020p:plain

写真は粗いし作りも雑でお恥ずかしいレベルですが、この段階でのクオリティは気にしない。とは言え、これでも手に取ってみるとなかなかの達成感があります。※1

次に、実際にこの手作りの試作品を遠方に住む自分の母親に予告なしで送りつけてみて、電話で感想をきいてみました。
いきなり送りつけられた母親はとても驚いていましたが、電話の向こうで涙ぐむほど喜んでいて、送ったこちら側はガッツポーズできるほどの達成感。同時に、伝わりきらなかった部分のヒアリングもでき、テストとしては十分な手応えを掴むことができました。

本番の企画を実施

上記のテストのフィードバックを踏まえ、企画を本格的に実施すべく動かし始めました。

企画を立てた当初は、ユーザーが自分のレシピを選んでサクッと応募できる仕組みを開発するつもりでいました。
しかし、スケジュール的にも開発リソース的にも無理がある。そこで既存の社内のツールを色々調べてみると、アンケートフォームと住所取得フォーム、確認のメールを直接やり取りできる仕組みなどがありました。
これらを組み合わせれば、なんとかできないこともなさそうです。
もちろん、既存のアンケートフォームの仕様に縛られるので、ユーザーが入力するテキスト量が多くなり、手間をかけさせてしまう部分もあります。
そこで、事故を防ぐために気をつけたのは以下の2点です。

  • ユーザーさんから新たに受け取る情報を最小限にする
  • 仕様をシンプルにしてユーザーさんの考えるコストを減らす

具体的には、レシピのフォーマット内に既に書かれている内容をそのままフォトブックに採用。これで応募時のテキスト入力の手間やミスが減ります。また、フォトブックの仕様は「写真入りレシピ5品」という1パターンのみに統一し、余計な選択するための思考コストをなくしました。
一方で、試作の段階では自分(娘)が母親のために作った世界で1冊のフォトブックである、という部分が伝わりきらなかったので、冒頭の1ページ目にお母さんへのオリジナルメッセージを入れる仕様に。ここで皆さんがお母さんへの思いを伝えられるようにしました。

このように既存のフォームを活用してなんとか応募の裏側の仕組みを整え、表側は1枚の告知ページだけを用意することで、実装のコストを最小限に抑えることができました。

スマートな応募フォームを用意できるに越したことはありませんが、最終的なゴールはユーザーが満足できるフォトブックを作成してお届けすること。今回の企画のように、スケジュール内でその目的を達成するため、多少の使い勝手が下がっても実現可能な方法を選択したほうが良い場合もあります。
結果的に大きな事故もなく、皆様に素敵なフォトブックを作成してお届けすることができました。※2 応募してくださった方からはメールやブログなどでの好意的な反響が通常の6倍ほどもあり、大変ご満足いただけたという印象です。

まとめ

何か施策を進めようとする時、まずはエンジニアのリソース確保…と考えてしまいがちですが、ディレクターだけでできることは色々あります。紙や既存のツール、SNSやリアルな人間関係などを駆使すればある程度のテストも可能。
エンジニアには本当に必要な開発に集中してもらえるよう、今後もこれらの手法を常に意識して取り組んでいきたいです。

※1 社内のプリンターでA4用紙に印刷したものをハサミで切ってビニール製のポケットブックに入れ、マスキングテープを貼って綴じただけのもの。
※2 本企画でユーザーの皆様にお届けしたものは専門業者さんに製本してもらった素敵なものです。ご安心ください。

インフラ新卒研修と社内ISUCONのはなし

インフラ部の荒井(@ryot_a_rai)です。

今年の4月、弊社には11名の新卒エンジニアが入社しました。そして現在、3ヶ月間の研修を受けています。ビジネスマナーから技術研修まで幅広く行われていますが、その中で5月下旬におこなったインフラ研修とその後の社内ISUCONについてご紹介します。

インフラ研修(講義)

f:id:ryotarai:20160610152008j:plain

インフラ研修はインフラ部に配属されるエンジニアに限らず、全新卒エンジニアが参加する研修です。日常業務でコードを書いてサービスを開発していくうえで知っておいてほしい、インフラに関する基礎知識や共通言語を獲得することを目的としています。合計3日間をインフラ部の@kani_bと分担して講義しました。研修内容の内容はざっくりと以下のようなものです。

1日目

  • インターネットとは
    • ブラウザでウェブサイトを閲覧する際になにが起きているのか
    • IPからHTTPまでざっくりと
  • Webインフラアーキテクチャ概観
    • 三層アーキテクチャ(Webサーバ層、Webアプリーケーション層、データベース層)の話
    • スケールイン/アウト
  • Vim超入門
  • アプリケーションサーバ
    • Rack, Ruby on Railsの仕組み、役割
    • WEBrickやUnicornなどのHTTPサーバの話

2日目

  • データストア
    • MySQL
      • スロークエリ, explain, インデックス
      • トランザクション, ロック
      • レプリケーションとスケールアウト
    • Memcached
      • slab allocator, consistent hashingなど使う上で知っておきたい知識
    • Redis
      • Memcachedに比べた利点や使いどころについて
    • 全文検索
      • Solr, Elasticsearchの役割や利点について
  • Webサーバとプロキシ
    • nginx
      • 静的ファイルの配信
      • Unicornなどアプリケーションサーバの前段に配置する意味

3日目

  • その他の構成要素
    • キャッシュ
      • Railsキャッシュストア
      • Varnishによるレスポンスのキャッシュ
    • CDN
      • CDNの役割や利点について
    • バッチ処理
      • バッチ処理とはなにか
      • 弊社のバッチ処理環境について
      • バッチを書く際に気をつけるべきこと
  • Infrastructure as Code
    • なぜコードで記述するか
    • Itamae, Serverspecによるサーバプロビジョニング
    • これまでの手作業を自動化してみる
  • いわゆる”クラウド”について
    • AWSなどいわゆるクラウドサービスの利点と各種サービスについて

全体として自分で触って覚えられるようにハンズオンを多くおこないました。ひとりひとりVirtualBox( + Vagrant)でVMを構築し、その中で演習をできるようにしています。例えば、自分で実装したRackアプリケーションをWEBrickやUnicornで動かしたり、nginxを前段に挟んでベンチマークを取って効果を確認したりしました。

段階グランプリ(社内ISUCON)

4日目は新卒研修のフィナーレとして段階グランプリ(社内ISUCON)を開催しました。ISUCONは年に一回開催されているパフォーマンスチューニングコンテストで、段階グランプリはその社内版です*1。段階グランプリは新卒研修の一環ですが、せっかくなら新卒以外の社員にも参加してもらおう、ということでエンジニア全体で参加者を募り開催しました。参加者は新卒11名(4チーム)、新卒以外23名(9チーム)となり大盛況でした。

準備

今回の段階グランプリの準備・運営はインフラ部の3名(@mirakui, @kani_b, 私 @ryot_a_rai)でおこないました。具体的には以下の準備をしました。

  • 参加者ポータルサイト(mirakui)
    • ベンチマークを実行したり、ベンチマーク結果を見たり
    • 社内の参加者以外からも見えるようにして、お気に入りのチームを応援できるようにしました
    • 素敵なドメイン(段階.jp)でアクセスできるようにしました
  • 参考実装(ryot_a_rai)
    • 複数言語実装は用意せず、Ruby on Railsでの実装のみを用意しました(弊社で最も多く利用されている言語・フレームワーク)
    • 新卒研修で学んだことを活かせるよう、N+1クエリ、スロークエリなど日常でお目にかかるような消耗ポイントを用意しました
    • テーマは「雑実装なクックパッド」でした
  • ベンチマーク(ryot_a_rai)
  • サーバの準備(kani_b)
    • 参加者の環境やベンチマーカなどを用意
    • 今回はAWS EC2上で1チームにつきc4.largeを3台(io1 EBS 100IOPS)を用意しました
      • gp2を使っていないのはバーストを防ぐため
    • メインのAWSアカウントとは別のアカウントを利用していたため、インスタンス数の制限に引っかかって、急いで緩和申請を上げました…

余談ですが、新卒研修準備、社内ISUCONの開発合宿に利用したヴィラージュ伊豆高原がよかったです。会議室や部屋によっては大きめのテーブルがあり夜中まで開発をやっていけますし、いい感じの温泉がありました。

結果

f:id:ryotarai:20160610151941p:plain

最終結果は上のようになりました。本家ISUCON本選出場勢(@sora_h, @eagletmt)が大人げないスコアで優勝しましたが、新卒チームも初期スコアの4, 5倍のスコアを出し、新卒研修の成果が見てとれました。参加者の感想もおおむね好評で今後も定期的に開催していきたいと考えています。

まとめ

以上、今年のインフラ研修についてご紹介しました。今年は講義形式や社内ISUCONが初回だったこともあり、準備も大変でしたが、今年の経験を来年以降にも繋げられるといいと思っています。こんな新卒研修や社内ISUCONに参加したい、主催したいというあなた、ぜひ一緒にやっていきましょう!

*1:本家ISUCONについてはこちら