ごんれのラボ

iOS、Android、Adobe系ソフトの自動化スクリプトのことを書き連ねています。

ExtendScript Debugger + TypeScript で ExtendScript を書くためのテンプレートを作った

概要

ExtendScript DebuggerというVisual Studio CodeプラグインがAdobeからリリースされた。
いままで利用されていたExtendScript Toolkitの開発が凍結されたので、以降の開発はこのExtendScript Debuggerを利用して行うことになった。
その都度環境を作るのが面倒で自分用に雑にテンプレートを作ったので、ついでにGitHubで公開することにした。

ExtendScript Debuggerの導入

Adobe Tech BlogのExtendScript Debugger for Visual Studio Code Public Release を読む。
そのあと、id:uske_S さんのExtendScript Debugger for VSCodeがリリースされたので簡単にまとめてみたを読む。

仕様

  • 型定義としてten-A/Types-for-Adobeを利用した
  • TypeScriptで書けるようにした
  • 各種configは暫定的な内容になっている。随時更新予定

使い方

$ npm install -g typescript # when not installed typescript
$ git clone git@github.com:macneko-ayu/template-for-extendscript-using-typescript.git
$ cd template-for-extendscript-using-typescript
$ npm install 
$ npm run watch

src/app.ts を変更して保存すると、dist/ の中に app.js が書き出される。
書き出された app.js をVisual Studio Codeで実行するか、アプリケーションから実行する。

ソースコード

https://github.com/macneko-ayu/template-for-extendscript-using-typescript

Swiftで文字列の輪郭に線をつける(UIBezierPath版)

概要

Swiftで文字列の輪郭に線をつける という記事を書いたんだけど、仕上がりに納得がいかなかったので別のアプローチで実現してみた。

ゴール

Illustratorのアピアランス機能で作って画像化したものと近い感じにする。

illustrator

問題点

  • NSAttributedStringattributes で直接線をつけていたため、線の太さに応じて塗りの範囲が狭くなってしまった
  • 文字が角ばり、丸くできない

実現方法

文字を UIBezierPath に変換して描画するようにした。
実装は 【iOS】テキストの輪郭パスを取得する を参考にさせていただき、Swiftに書き直した。
あわせて TextOutlineShapeView というカスタムViewを作って、その中で描画するようにした。
構造としては文字列を UIBezierPath に変換して、そのPathをセットした CAShapeLayer を3つ重ねて、それぞれ「塗り」「線」「影」の設定を適用した。

TextOutlineShapeView の実装は以下の通り。

import UIKit

class TextOutlineShapeView: UIView {
    struct TextOptions {
        let text: String
        let font: UIFont
        let lineSpacing: CGFloat
        let textAlignment: NSTextAlignment
    }

    struct ShapeOptions {
        let lineJoin: CAShapeLayerLineJoin
        let fillColor: CGColor
        let strokeColor: CGColor
        let lineWidth: CGFloat
        let shadowColor: CGColor
        let shadowOffset: CGSize
        let shadowRadius: CGFloat
        let shadowOpacity: Float
    }

    private var textOptions: TextOptions?
    private var shapeOptions: ShapeOptions?

    init(textOptions: TextOptions, shapeOptions: ShapeOptions) {
        self.textOptions = textOptions
        self.shapeOptions = shapeOptions
        super.init(frame: .zero)
        configure()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
}

extension TextOutlineShapeView {
    private func configure() {
        guard let textOptions = self.textOptions,
            let shapeOptions = self.shapeOptions else {
                return
        }
        let textArray = textOptions.text.components(separatedBy: "\n")
        let attibutes: [NSAttributedString.Key: Any] = [.font: textOptions.font]
        let attibutedStrings = textArray.map { text -> NSAttributedString in
            return NSAttributedString(string: text, attributes: attibutes)
        }
        guard let bezier = makePathfromText(attibutedStrings: attibutedStrings, lineSpacing: textOptions.lineSpacing, textAlignment: textOptions.textAlignment) else { return }
        bezier.flip(direction: .y)

        let shadowLayer = CAShapeLayer()
        shadowLayer.lineJoin = shapeOptions.lineJoin
        shadowLayer.frame = self.bounds
        shadowLayer.shadowPath = bezier.cgPath
        shadowLayer.shadowColor = shapeOptions.shadowColor
        shadowLayer.shadowOffset = shapeOptions.shadowOffset
        shadowLayer.shadowRadius = shapeOptions.shadowRadius
        shadowLayer.shadowOpacity = shapeOptions.shadowOpacity
        self.layer.addSublayer(shadowLayer)

        let strokeLayer = CAShapeLayer()
        strokeLayer.lineJoin = shapeOptions.lineJoin
        strokeLayer.frame = self.bounds
        strokeLayer.strokeColor = shapeOptions.strokeColor
        strokeLayer.lineWidth = shapeOptions.lineWidth
        strokeLayer.path = bezier.cgPath
        self.layer.addSublayer(strokeLayer)

        let fillLayer = CAShapeLayer()
        fillLayer.lineJoin = shapeOptions.lineJoin
        fillLayer.frame = self.bounds
        fillLayer.fillColor = shapeOptions.fillColor
        fillLayer.path = bezier.cgPath
        self.layer.addSublayer(fillLayer)
    }

    private func makePathfromText(attibutedStrings: [NSAttributedString], lineSpacing: CGFloat, textAlignment: NSTextAlignment) -> UIBezierPath? {
        let path = UIBezierPath()
        path.move(to: .zero)

        var maxWidth: CGFloat = 0
        for attibutedString in attibutedStrings {
            let line = CTLineCreateWithAttributedString(attibutedString)
            let rect = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
            let width = rect.width

            if maxWidth < width {
                maxWidth = width
            }
        }

        for (i, attibutedString) in attibutedStrings.reversed().enumerated() {
            let letters = CGMutablePath()

            let line = CTLineCreateWithAttributedString(attibutedString)
            let rect = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
            let width = rect.width

            var margin: CGFloat = 0
            switch textAlignment {
            case .center:
                margin = (maxWidth - width) / 2
            case .right:
                margin = maxWidth - width
            default:
                break
            }

            let runArray: [CTRun] = cfArraytoArray(sourceArray: CTLineGetGlyphRuns(line))
            for run in runArray {
                let fontPointer = CFDictionaryGetValue(CTRunGetAttributes(run), Unmanaged.passUnretained(kCTFontAttributeName).toOpaque())
                let runFont = unsafeBitCast(fontPointer, to: CTFont.self)

                for index in 0..<CTRunGetGlyphCount(run) {
                    let thisGlyphRange = CFRange(location: index, length: 1)
                    var glyph = CGGlyph()
                    var position = CGPoint()
                    CTRunGetGlyphs(run, thisGlyphRange, &glyph)
                    CTRunGetPositions(run, thisGlyphRange, &position)

                    guard let letter = CTFontCreatePathForGlyph(runFont, glyph, nil) else { continue }
                    let px = position.x + margin
                    let py = position.y + path.bounds.height + ((i == 0) ? 0 : lineSpacing)
                    let t = CGAffineTransform(translationX: px, y: py)
                    letters.addPath(letter, transform: t)
                }
            }

            path.append(UIBezierPath(cgPath: letters))
        }

        return path
    }

    private func cfArraytoArray<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, to: T.self)
            destinationArray.append(value)
        }
        return destinationArray
    }
}

最初実装したときになぜか上下反転して表示されてしまった。
why

transformがうまくいっていなかったようなので、 UIBezierPathのextensionを実装した。
extensionの実装は以下の通り。

import UIKit

extension UIBezierPath {
    enum InvertDirection {
        case none
        case x
        case y
        case both
    }

    func flip(direction: InvertDirection) {
        let rect = self.bounds
        switch direction {
        case .none:
            break
        case .x:
            self.apply(CGAffineTransform(translationX: -rect.origin.x, y: 0))
            self.apply(CGAffineTransform(scaleX: -1, y: 1))
            self.apply(CGAffineTransform(translationX: rect.origin.x + rect.width, y: 0))
        case .y:
            self.apply(CGAffineTransform(translationX: 0, y: -rect.origin.y))
            self.apply(CGAffineTransform(scaleX: 1, y: -1))
            self.apply(CGAffineTransform(translationX: 0, y: rect.origin.y + rect.height))
        case .both:
            self.apply(CGAffineTransform(translationX: -rect.origin.x, y: -rect.origin.y))
            self.apply(CGAffineTransform(scaleX: -1, y: -1))
            self.apply(CGAffineTransform(translationX: rect.origin.x + rect.width, y: rect.origin.y + rect.height))
        }
    }
}

使い方

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // For example
        let str = "我が家の猫は7.4kg\nゆえに重い"
        guard let font = UIFont(name: "HiraKakuProN-W6", size: 36) else { return }

        let textOptions = TextOutlineShapeView.TextOptions(text: str,
                                                           font: font,
                                                           lineSpacing: 10,
                                                           textAlignment: .left)
        let shapeOptions = TextOutlineShapeView.ShapeOptions(lineJoin: .round,
                                                             fillColor: UIColor.white.cgColor,
                                                             strokeColor: UIColor.blue.cgColor,
                                                             lineWidth: 5,
                                                             shadowColor: UIColor.black.cgColor,
                                                             shadowOffset: CGSize(width: 3, height: 3),
                                                             shadowRadius: 5,
                                                             shadowOpacity: 0.6)
        let shapeView = TextOutlineShapeView(textOptions: textOptions, shapeOptions: shapeOptions)
        self.view.addSubview(shapeView)

        shapeView.translatesAutoresizingMaskIntoConstraints = false
        let top = shapeView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 10)
        let bottom = shapeView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: 10)
        let leading = shapeView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor, constant: 10)
        let trailing = shapeView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor, constant: 10)
        NSLayoutConstraint.activate([top, bottom, leading, trailing])
    }
}

sample

いい感じ。
実用するなら描画後に画像化したほうがパフォーマンスがいいと思う。

大きく表示したサンプル

角の処理結果が見づらいので、文字サイズを大きくした。

NSAttributedString で作ったもの。美しくない…。
bad

UIBezierPath で作ったもの。美しい。
good

ソースコード

https://github.com/macneko-ayu/TextOutlineShapeView

Swiftで文字列の輪郭に線をつける

概要

Swiftで袋文字を実現したいという話を聞いて、調べたら実現方法がさっくり見つかったので残しておくことにした。

実現方法

UILabelextension を定義して、そのメソッドを使うだけ。
参考記事のままだとエラーになるのでよしなに修正してある。

extension UILabel{

    /// makeOutLine
    ///
    /// - Parameters:
    ///   - strokeWidth: 線の太さ。負数
    ///   - oulineColor: 線の色
    ///   - foregroundColor: 縁取りの中の色
    func makeOutLine(strokeWidth: CGFloat, oulineColor: UIColor, foregroundColor: UIColor) {
        let strokeTextAttributes = [
            .strokeColor : oulineColor,
            .foregroundColor : foregroundColor,
            .strokeWidth : strokeWidth,
            .font : self.font
        ] as [NSAttributedString.Key : Any]
        self.attributedText = NSMutableAttributedString(string: self.text ?? "", attributes: strokeTextAttributes)
    }
}

見た目があまり美しくないので、静的な用途であればIllustratorなどのアプリケーションでSVG書き出ししたものを使うと良い。

Playground

Playgroundにコードをコピペして実行するとどんな感じが確認できる。

import UIKit

extension UILabel{

    /// makeOutLine
    ///
    /// - Parameters:
    ///   - strokeWidth: 線の太さ。負数
    ///   - oulineColor: 線の色
    ///   - foregroundColor: 縁取りの中の色
    func makeOutLine(strokeWidth: CGFloat, oulineColor: UIColor, foregroundColor: UIColor) {
        let strokeTextAttributes = [
            .strokeColor : oulineColor,
            .foregroundColor : foregroundColor,
            .strokeWidth : strokeWidth,
            .font : self.font
        ] as [NSAttributedString.Key : Any]
        self.attributedText = NSMutableAttributedString(string: self.text ?? "", attributes: strokeTextAttributes)
    }
}

let label = UILabel(frame: CGRect(x: 0, y: 0, width: 500, height: 60))
label.font = UIFont.boldSystemFont(ofSize: 50)
label.text = "我が家の猫は7.4kg"
label.makeOutLine(strokeWidth: -2.0, oulineColor: .white, foregroundColor: .blue)

キャプチャ

uilabel-outline

おまけ

NSAttributedString.Key でいろいろ装飾できるので、ドキュメントを読むと面白い。
NSAttributedString.Key

iOSでチラシレイアウトも実現できる。需要はない。
iOSでチラシっぽい価格レイアウトを再現してみた

参考

元ネタ

Custom Label Effects in Swift 4

個人的に毎度読むスライド

最高
Mastering Textkit

【実績紹介】デジタル・アド・サービス様 InDesign用スクリプト納品

概要

デジタル・アド・サービス様にInDesign用のスクリプトを2案件納品させていただきましたので、問題のない範囲でどういったスクリプトか、工夫した点はどこかなどをご紹介します。
また汎用的に使えそうな部分(&コアじゃない部分)の処理の一部はスクリプトを記載しています。
なお、この記事の内容はデジタル・アド・サービス様の許可をいただいています。

どういったスクリプトを納品したか

どちらのスクリプトもカタログの組版データから、必要な情報をテキストファイルに書き出すというものです。
ひとつは表組内の製品番号と同行にある価格を走査して製品番号と一緒にテキストファイルに抜き出し、もうひとつは製品番号が含まれる表組のページ内の面積占有率を計算してテキストファイルに抜き出します。

工夫した点

製品番号をページ内からどう検索するかについて

この処理は製品番号が含まれているテキストフレームの構造を理解する必要がありました。
該当するテキストフレームはページ内に存在し、かつ表組みが含まれているものであると仮定して、その条件にマッチするテキストフレームを抽出し、配列化しました。
また、あわせてページ内のテキストフレームを左上から右下に向かって並び替える処理も行っています。

実装したスクリプトの一部を以下に掲載します。

/**
 * Tableを含むTextFrameを抽出
 * @param  {Array} txFrames TextFrameの配列
 * @return {Array}          TextFrameの配列
 */
function extractTxFrameContainingTable(txFrames) {
    var result = [];
    for (var i = 0, txFrameLen = txFrames.length; i < txFrameLen; i++) {
        if (txFrames[i].tables.length > 0) {
            result.push(txFrames[i]);
        }
    }
    return result.length > 0 ? result : undefined;
}

/**
 * pageオブジェクト内のTextFrameを抽出して座標順にソート
 * @param  {Page} currentPage Pageオブジェクト
 * @return {Array}            TextFrameの配列
 */
function extractTxFrameInPage(currentPage) {
    var tfsArray = [];
    for (var i = 0, len = currentPage.allPageItems.length; i < len; i++) {
        var pageItem = currentPage.allPageItems[i];
        if (pageItem.constructor.name == 'TextFrame') {
            tfsArray.push(pageItem);
        }
    }
    return sortObjectsByCoordinateOrder(tfsArray);

    /**
     * 座標順(昇順)にソート
     * @param  {Collection} objects 座標値をもつオブジェクトのコレクション
     * @return {Array}              ソートした配列
     */
    function sortObjectsByCoordinateOrder(objects) {
        var sorted = objects.concat();
        sorted.sort(function (a, b) {
            // Y軸比較
            if (a.geometricBounds[0] < b.geometricBounds[0]) {
                return -1;
            }
            if (a.geometricBounds[0] > b.geometricBounds[0]) {
                return 1;
            }
            // X軸比較
            if (a.geometricBounds[1] > b.geometricBounds[1]) {
                return 1;
            }
            if (a.geometricBounds[1] == b.geometricBounds[1]) {
                return 0;
            }
            return -1;
        });
        return sorted;
    }
}

製品番号から価格を走査する処理について

この処理はInDesignの機能である正規表現検索と、表組のセル内のテキストを走査することで実現しました。
ここの処理がこの案件の要ともいえる部分で、結構苦労しましたし、何度かゼロから書き直したりしました。

正規表現検索

製品番号や価格は一定のルールで構成されているため、そのルールに則り正規表現のパターンをいくつか用意して配列化し、その配列分の検索を繰り返して検索を行いました。
また、その際に精度をあげるため段落スタイルも検索条件に加えました。こちらはドキュメント内のすべての段落スタイルの中から、製品番号や価格に使われているであろうスタイル名をもとに抽出して配列化しています。スタイルグループにも対応しました。

表組のセル内のテキスト走査

「製品番号と価格が同行にある価格を抜き出す」という条件があったので、それを実現するために製品番号を上述した正規表現検索で検索して、ヒットした製品番号が含まれているセルから右にあるセルを順番に処理し、その中に価格が含まれていたら抽出する処理を実装しました。
また、追加要件として製品番号と同行に価格がなかったら次の行にある価格を走査して抽出するという要望があったので、そちらもさくっと実装しました。

面積計算の前準備スクリプトについて

製品番号ごとの面積占有率を計算するにあたりオペレーターの作業が必要になるため、前準備スクリプトを実装しました。
このスクリプトは既存のドキュメントに対して以下のような処理を行います。

  • 段落スタイルを作成
  • レイヤーを作成
  • オブジェクトスタイルを作成
  • スウォッチを作成
  • フレームをひとつ作成して、オブジェクトスタイルとスウォッチを適用

このスクリプトのルールに則っていないフレームは処理の対象外とすることとし、後述する面積計算の処理をどのフレームに対して行えばよいかを明確にできました。

面積占有率計算について

ページ内の特定のレイヤーに存在する特定のオブジェクトスタイルが適用されているフレームを対象に、ページ内の面積占有率を計算しました。
また、上記のフレームの上に重なるように採番用のIDを流し込んだテキストフレームを作成し、IDは面積占有率やページ内の何番目のフレームかがわかるように、スクリプト内で自動生成しました。

その他

ワークフローや仕様要件の提案

対面やSlackでの打ち合わせをしたときに既存のワークフローをお聞きして、スクリプトでどう効率化するのかの提案や仕様要件の提案を行いました。
とはいえ、スクリプトを書ける担当者様からのご依頼だったので、

担「こんな風にスクリプトでできると思うのですが可能ですか?」
私「できますね。難しいとおっしゃってたこのパターンもこういう条件なら対応できますが、そういう処理で大丈夫ですか?」
担「いいですね!それでいきましょう!」

といった感じにスムーズに提案を受け入れていただいて、手戻りがあまり発生せずに進めることができました。
また、仕様要件や成果物であるテキストファイルの仕様、使い方をドキュメントとして納品しました。

処理の共通化

二案件同時にご依頼があったこともあり、処理の共通化を念頭において仕様要件を決めました。
結果的に製品番号を検索する処理、テキストを抽出する処理、レイヤーのロックを解除する処理、テキストファイルを保存する処理、スタイルを検索する処理と、ほとんどの処理を共通化できました。
ライブラリとしてincludeすることも検討したのですが、スクリプト単体で運用できるほうが取り回しやすいだろうということで、ライブラリ化は見送りました。

Undoの履歴に残らないようにした

大量の処理を行うので、Undoの履歴に残らないように doScript() を利用しました。

app.doScript (main, ScriptLanguage.JAVASCRIPT, [], UndoModes.ENTIRE_SCRIPT);

最後に

デジタル・アド・サービス様に納品したInDesign用スクリプトについてご紹介しました。
快く実績紹介に応じてくださったデジタル・アド・サービス様、ありがとうございました。

技術検証用にReact Nativeでサンプルアプリを作った

概要

React Nativeで、Haptic FeedbackとLottieを利用したアニメーションが実現できるのか、簡単なアプリを実装して試してみた。

実現できたか

結果から書くと、動作させること自体はとても簡単だった。
まったくのReact Native初心者の私でも環境構築含め一日程度でできた。
アプリへの組み込みは開発者の実装速度に依存するところが大きいが、ちょっと頑張ればいけるという肌感。

サンプル紹介

Haptic Feedbackの実装

以下のリポジトリでサンプルアプリを公開している。
https://github.com/macneko-ayu/RNHapticFeedbackSample

READMEに書いているとおりだけど、react-native-haptic-feedbackというパッケージを導入した。

アプリを起動すると以下のように画面上にボタンがいくつか表示される。

screen

ボタンのラベルの文字列はHaptic Feedbackとして用意されている設定と同期していて、各ボタンをタップするとその設定のHaptic Feedbackが作動する。
Haptic Feedbackを実装する際はOS標準のアプリのHaptic Feedbackを参考にして、同じ世界観になるようにこの設定の中から選択する必要があると思う。

Lottieの実装

以下のリポジトリでサンプルアプリを公開している。
https://github.com/macneko-ayu/RNLottieSample

READMEに書いているとおりだけど、lottie-react-nativeというパッケージを導入した。

アプリを実行するとLottieのリポジトリにあったサンプルのアニメーションが実行される。

output

サンプルではアニメーションを読み込んでループ再生しているだけだが、実際のアプリではスクロール時に実行したり、ボタンタップ時に実行したりと、イベントに連動するように使われるので、もう少し工夫する必要があると思う。

React Nativeについて

環境構築

公式のGetting Startedの通りにやれば大体できる。
私の環境では node_modules のPATHが通っておらずコケてしまったので、以下の記事を参考にしてPATHを通した。
ReactNativeを使ってみる

iOSの場合はいつもの如くXcodeでCode Signと戦ってくれ。
Androidは実機がなくて試してない。

所感

とにかくビルドが遅い。
Swiftがとてもかわいく思えるくらい遅い。
Hot Reloadできたりするので、積極的に利用するといい。

最後に

この程度のサンプルアプリを公開することに若干の抵抗があったけど、誰かの役に立てれば幸い。

Homebrewでreadlineのバージョンを切り替える方法

方法

readlineのバージョンを brew info readline で調べる。

$ brew info readline
readline: stable 8.0.0 (bottled) [keg-only]
Library for command-line editing
https://tiswww.case.edu/php/chet/readline/rltop.html
/usr/local/Cellar/readline/7.0.3_1 (46 files, 1.5MB)
  Poured from bottle on 2018-06-26 at 09:48:16
/usr/local/Cellar/readline/7.0.5 (46 files, 1.5MB)
  Poured from bottle on 2018-07-30 at 18:56:16
/usr/local/Cellar/readline/8.0.0 (48 files, 1.5MB)
  Poured from bottle on 2019-02-01 at 11:40:21
From: https://github.com/Homebrew/homebrew-core/blob/master/Formula/readline.rb
==> Caveats
readline is keg-only, which means it was not symlinked into /usr/local,
because macOS provides the BSD libedit library, which shadows libreadline.
In order to prevent conflicts when programs look for libreadline we are
defaulting this GNU Readline installation to keg-only.

For compilers to find readline you may need to set:
  export LDFLAGS="-L/usr/local/opt/readline/lib"
  export CPPFLAGS="-I/usr/local/opt/readline/include"

For pkg-config to find readline you may need to set:
  export PKG_CONFIG_PATH="/usr/local/opt/readline/lib/pkgconfig"

==> Analytics
install: 493,453 (30 days), 992,713 (90 days), 3,374,936 (365 days)
install_on_request: 57,851 (30 days), 99,242 (90 days), 316,807 (365 days)
build_error: 0 (30 days)

使いたいバージョンに切り替える。

brew switch readline 7.0.5

なぜ

気軽に brew install hoge した結果、readlineがUpdateされて、Tigが使えなくなったので、readlineのバージョンを切り替えたかった。

$ tig
dyld: Library not loaded: /usr/local/opt/readline/lib/libreadline.7.dylib
  Referenced from: /usr/local/bin/tig
  Reason: image not found
zsh: abort      tig

他に影響がないかはそのとき考える…。

参考

https://blog.skylarking.me/2016/10/07/psql-readline-error/

iOSアプリ設計ナイトに行ってきたよ

概要

pixiv主催の「iOSアプリ設計ナイト」という勉強会に参加してきたので、雑にメモ。
https://pixiv.connpass.com/event/112561/

セッション

「2つの同期 4つの状態」by ダンボーさん

  • 資料
  • アーキテクチャにはふたつある
    • GUIアーキテクチャ
      • MVC、MVP etc...
    • システム・アーキテクチャ
      • クリーンアーキテクチャ etc...
  • 2つの同期
    • オブザーバー同期
    • フロー同期
  • オブザーバー同期
    • 監視によるデータ同期
    • データフローが追いづらい
  • フロー同期
    • 手続きによるデータ同期
    • データフローが追いやすい
    • 遠い場所にあるコンポネント同士の同期が難しい
  • 4つの同期
    • Screen State
      • Viewの状態
    • Presentation State
      • Presenter、ViewModelの状態
      • 表示に使うための中間データ
      • 例:Twitterのいいねボタン
    • Session State
      • Modelの状態
      • オンメモリで管理するRecord Stateのコピーのデータ
    • Record State
      • DataStoreの状態
      • 永続化状態
        • Realmなど
  • どのデータをどのデータと同期するか
    • クリーンアーキテクチャの役割で対応するStateを見る
      • イメージしやすくなるため登場したが、本来はシステム・アーキテクチャ
  • PTSをどう実現するか
    • ScreenとRecordをどう同期するか
    • State同士の距離と同期コスト(=エンジニアがコードを追うコスト、書くコスト)
    • 中間層を抜くとコストは低くなるが、Fatになる
    • State同士をつなぐ様々なパターン
      • Screen Stateと他のどのStateを同期するか
    • 要件、コストなどを考慮して、君だけのオリジナルのアーキテクチャを手に入れる
  • パターンから見る
    • APIから取得したデータを画面に表示するだけ
      • ScreenとSessionを直接同期させればOK
    • APIから取得したデータが必ず同期するわけではない
      • 同期するかしないかを管理するため、中間データで管理する必要がある
      • Presenterに一度保持して処理をする
      • MVP
    • 同期のパターンが2つあって管理するとややこしい
      • MVP(Passive View)
      • フローで同期するので追いやすい
      • 間にPresenterを置く必要があるので、ちょっと手間
  • GUIアーキテクチャを理解する
    • 距離の調節によって責務分割の仕方が変わる
    • 距離は同期方法と中間処理によって異なる
  • まとめ
    • 各アーキテクチャパターンで状態の持ち方と同期方法に違いがある
    • 必要な状態が増えてきた歴史があって、それぞれの同期方法の違いで各種アーキテクチャパターンが生まれているといってもいい

「橋の下で安心して寝るための『iOSアプリ設計パターン入門』」 by kameikeさん

  • 橋の下で寝ている自分がたどり着いた橋の下で寝ているということは自信をもっていい
    • ローマ時代は橋を作った後に橋の下で寝なければいけなかったらしい
    • 「反脆弱性[上]――不確実な世界を生き延びる唯一の考え方 」から引用
  • Palcyの実務の話
    • マンガアプリ
    • 数年間保守をしていくことになる
  • MVVMでもよりより形を求めて変化していった
    • テスタビリティにするなど
  • 漸進的成長のTips
    • プロジェクトを縦に割る
      • 縦割りのフォルダ校正
        • 画面ごとに必要なものをまとめた
      • 横割りだとどのファイルが最新で、更新対象かわかりづらい
      • ChangeLogと対応が取りやすい
      • 各画面ごとにReadme.mdが置けるので、現状の背景を他の人に伝えやすい
    • 腕試しのBLoCパターン
      • 小さく導入して効果を実感できるオーバーヘッドが少ないのでオススメ
      • 影響範囲が限定的なので試しやすい
      • BLoCを導入したことで発展する先の道筋が見えやすくなった

「iOSアプリ設計パターン選定」 by d_dateさん

  • 同設計すべきか
    • 機能要件はなに
    • 非機能要件はなに
    • チームのスキルやリソースは
    • リードできるエンジニアはいるか
    • そのアーキテクチャを好きになれるか
  • 非機能要件を想像する
    • 品質レベル
    • どのぐらいの開発期間
    • 通信失敗時のハンドリング
    • エラーパターンはどのぐらいあるか
  • 設計パターンに正解はない!
    • マッチすると思ったものを使う
  • ドメイン駆動設計(DDD)
    • Repository
      • 詳細はtakasekさんのQiitaの記事を見て
      • 永続化の手段
      • Repositoryの中で通信したものとCacheとを判別する処理を書く
        • Cacheはファイルでもいいし、DBでもいいし、UserDefaultsでもいい
      • Repositoryの中のコードは公開されないので、好きに書いていい
      • REST APIとCachaを区別しない=MockでもOK
        • テストが書きやすい

表示ロジックにおけるテスタビリティの獲得方法 by いしかわさん

  • 資料
  • テスト書きづらい、メンテがつらい、開発速度が遅い…これらはテスタビリティが低いせい。ベストではないがこれまで自分たちがやってきたことを発表
  • 画面内のどういうところがテストできるか
    • 画面表示時
      • インジケーターを表示
      • 読み込みを開始
    • 読み込み完了時
    • 星のタップ時
  • 一連のフローを上から下まで一気にテストするのは大変(かなりコストがかさむ)
    • UIテストは実装も実行も高コスト
      • UIの要素を通じて状態を検証する必要がある
      • 待ちが多くなるため反復実行に向かない
    • タイムラインの制御が難しい
      • 連打などの込み入った状況を再現できない
    • テスト対象の状況を用意しづらい
      • 外部のシステムの応答を切り替える必要がある
      • 環境によって不安定な結果になる
  • 解決:UIテストは実装も実行も高コスト
    • 検証しやすいモデルで表現する
      • テスト用のデータをstructで用意する
        • 画面の状態を表現するプロパティ
        • セルを表現する型
    • 状態を検証しやすくなった(実際の表示は検証していない)
    • UIテストでなければ検証できないものもある
    • 状況に応じて使い分けるとよい
  • 解決:タイムラインの制御が難しい
    • 仮想時間上でイベントを扱えばよいのでは
    • 仮想時間は単なる数値なので自由に制御できる
    • 仮想なので100秒も一瞬
    • 結果も仮想時間で検証できる
  • 解決:テスト対象の状況を用意しづらい
    • 依存をプロトコルにしてスタブ化する
    • APIClientをProtocolにする
      • テストのときにスタブのAPIClientに切り替えることができる
    • スタブだけなら実装は簡単
    • テストケースごとに任意のレスポンスを返せる
  • テストしやすい土俵で戦おう
  • サンプルコード

「iOSアプリ設計の何がつらいのか」 by takasekさん

  • 状態とフローのつらみ
    • オブジェクトの生存期間が長いからつらい
      • サーバーサイドと違ってアプリがずっと生きてて処理を待ち続けている
      • 処理が非同期に行われる
        • スレッドをブロックしてはいけない
    • 状態がミュータブルだから辛い
      • 純粋関数にすればよい?
      • どこかしらで副作用を取りまとめる必要がある
    • オブザーバーパターンで解決する
  • 要求変化のつらみ
    • つらいところ
    • 要求は変化する
    • どこが壊れるかわからない
    • リリースしたら直せない
    • 壊れたバージョンをリリースするといつまでも使われる可能性がある(意訳)
    • テストで解消しよう
      • コストが上がる気がするが小さく試せるので結果的にコストが減らえる(意訳)
    • リファクタリングで解消しよう
      • テストがあることでリファクタリングできる
  • 画面とモデルで求めるものが違うのでつらい
    • つらいところ
      • 画面によって興味が違う
      • 入力と出力がn:nになる
    • 関心の分離
      • コンポーネントの興味を小さくする
      • 適切にレイヤー化する
      • インターフェイスは小さくする
  • 外部との互換性がつらい
    • つらいところ
      • マイグレーションがつらい
    • スキーマを定める
    • レスポンス設計
      • バージョニングする
      • 新しいキーはoptionalに
  • つらみと戦う
    • つらみをたおす
      • 武器を知る
        • 設計パターン
      • 武器の使い方をしる
        • プラクティス
        • 原則
    • つらみからにげる
      • 分散コンピューティングの原則
      • 手段
      • 状態を減らしてもいい
      • ごちゃごちゃした画面でなくていい
      • 永続化しなくていい

感想

石川さんのセッションがとても良かったので、資料を再読しようと思う。