ANDPADの写真書き込み機能における、プロトコル準拠・継承を用いた拡張性を高めるための工夫

アンドパッドの山根です。

先日の11月13日、日本経済新聞社様が主催するテックイベント「NIKKEI Tech Talk」に、弊社が協賛し、私自身も登壇させていただきました。

【日経×ニフティ×アンドパッド】モバイルアプリでつなぐ現場と暮らし〜情報が“届く”を再設計する〜 - connpass

本記事では、そのイベントで発表した「写真書き込み機能」について、行ったエンハンスの概要とその実装の工夫を詳しく解説していきます。

ANDPAD 写真書き込み機能の強化

ANDPADには、写真上にフリーハンドの線や図形、テキストを自由に追加できる「写真書き込み機能」があります。

先日、写真書き込み機能は、建設現場の情報共有における正確性と効率を向上させるため、描画機能の大幅なエンハンスを実施しました。この機能強化により、現場写真への情報追記がより強力で効率的なものとなりました。

エンハンス後の追加機能と実現された効果

エンハンス前は、フリーハンドの書き込み、消しゴム、テキスト挿入といった基本的な機能を提供していました。

そして、今回のエンハンスでは、ユーザーの描画体験と機能の柔軟性を高めるため、以下の機能が新たに追加されました。

  • 図形(直線、矢印、四角、楕円)挿入機能
  • 図形のリサイズ、色、線の太さ変更など配置後の柔軟な編集機能
  • Undo・Redo機能

これらの機能強化の結果、情報伝達の正確性が向上しただけでなく、書き込み作業の効率化が実現し、現場監督や担当者がストレスなくスピーディに、詳細な情報を写真に残すことが可能になりました。エンハンス後の写真書き込み機能は、現場の情報共有の質とスピードを大きく引き上げ、建設現場の生産性向上に貢献しています。

エンハンス前・エンハンス後の写真書き込み機能

エンハンスにおける実装の工夫

そんな写真書き込み機能ですが、実装および設計上でいくつか工夫をしています。

アーキテクチャ・DrawingLayerProtocolによる抽象化

アーキテクチャはMVVMを採用しています。ViewModelは写真上に配置されたオブジェクトの情報を、DrawingLayerProtocolとして抽象化された状態で保持しています。

class PhotoEditingViewModel {
    // 写真上に追加されたオブジェクトなど
    @Published var drawingLayers: [any DrawingLayerProtocol] = []
}

protocol DrawingLayerProtocol {
    // オブジェクトが描画されるレイヤー
    var renderingLayer: CALayer { get }

    // renderingLayerを編集して、オブジェクトを描画する
    func draw()

    // オブジェクトの種類や色、線の太さ、座標など描画するために必要な情報
    var color: UIColor { get }
    var strokeWidth: Double { get }
    ...
}

写真上に書き込める、線や図形などを実装するクラスは、全てDrawingLayerProtocolに準拠しています。

DrawingLayerProtocolは、図形やテキストなどのオブジェクトが描画されるCALayerと、そのレイヤーに実際にオブジェクトを描画するdraw()メソッドが宣言されています。 また、オブジェクトの色、ストロークの太さ、座標など描画に必要な情報は全てDrawingLayerProtocolにまとめられています。

DrawingLayerProtocolに準拠したクラスをもとに、オブジェクトが描画されたCALayerをViewControllerのimageViewのサブレイヤーに追加することで、写真への書き込みを実装しています。

プロトコルに準拠した基底クラスの実装

プロトコルによって、描画に必要な処理をまとめて宣言していることを紹介しましたが、プロトコルに準拠したクラスの定義にも工夫を行っています。

各オブジェクトをDrawingLayerクラスとして定義しています。中にはプロトコルに準拠した基底クラスを定義して、それを継承することで実装されているクラスもあります。

各DrawingLayerの準拠と継承の関係は以下のようになっています。

DrawingLayerの準拠、継承の関連図

基底クラスによるロジックの共通化

本機能における基底クラスは、主にロジックの共通化の役割を担っています。

直線と矢印のオブジェクトを例にすると、「長さの変更」や「移動」のロジックは共通です。このような共通で実装されている機能に関わるロジックや選択時の長さを変更するための青いポインタの描画は基底クラスに実装することで共通化を行っています。 お互いに異なる点は、「何を描画するか(直線か矢印か)」のみなのでそれらは継承先のクラスで実装しています。

class ResizableLineDrawingLayer: DrawingLayerProtocol {
    func updateLength() {
        // 長さを更新
    }
    func move() {
        // オブジェクトの移動
    }
    // 他、サイズ変更のポインターを描画する処理など

    func draw() {
        assertionFailure(false, "Implement in sub-class!")
    }
}

// 共通化できないロジックは継承先でそれぞれ実装を行う
class LineDrawingLayer: ResizableLineDrawingLayer {
    override func draw() {
        //直線を描画する
    }
}

class ArrowDrawingLayer: ResizableLineDrawingLayer {
    override func draw() {
        //矢印を描画する
    }
}

同様に四角形と楕円もリサイズや移動、選択時のフレームの描画は共通化が可能です。 

class ResizableFrameDrawingLayer: DrawingLayerProtocol {
    func updateShape() {
        // 形を更新
    }
    func move() {
        // オブジェクトの移動
    }
    // 他、選択時のフレームを描画する処理など

    func draw() {
        assertionFailure(false, "Implement in sub-class!")
    }
}

一部の処理が継承によって共通化されている図形の様子

このような工夫によって、今後のアップデートを予期した機能の拡張性の向上を行いました。

エンハンスの振り返り・プロトコルの設計が実現した拡張性

今回の写真書き込み機能の大規模なエンハンスは、単なる機能追加に留まらず、今後の継続的な機能拡張を見据えた設計基盤の確立という側面も持っています。

最初のエンハンス後も、現場からの要望は途切れることなく続いています。現在では、機能がさらにアップデートされ、三角形の挿入機能や、描画した図形の角度調整機能などが追加されています。

このスムーズな機能追加の背景には、紹介した設計の工夫が効果的に機能しています。 プロトコルベースの設計を採用したことで、例えば「三角形」のような新しい図形を追加する際も、既存のコードに大きな変更を加えることなく、迅速かつ容易に実装することが可能となりました。

設計の見直しによって、機能の核となる描画ロジックを安定させつつ、現場からの新しい要望に対して柔軟に対応できる基盤を実装することができました。これこそが、今後のさらなる機能追加や、変化する顧客対応をスムーズに行うための最大の成果であると言えます。

さらなる設計の改善点

一方で、現行の設計にはさらなる改善の余地が残されています。

例えばResizableLineDrawingLayerResizableFrameDrawingLayerは、基底クラスとしてではなくプロトコルとして定義し、適宜デフォルト実装を提供する形がより望ましいと考えています。

プロトコルで設計することで、それに準拠する全ての型に対して、必須メソッドの実装(例えばdraw()の実装など)をコンパイル時点で強制できます。これにより、個々のレイヤー実装で必須メソッドの実装漏れを防ぎ、実行時のアサーションを不要とすることができます。これは、コードの堅牢性と、将来の開発における安全性を高める上で非常に重要です。

このように、今回のエンハンスで得られた知見を活かし、私たちは今後もより堅牢で拡張性の高いアーキテクチャを追求していきます。

登壇資料

登壇資料は以下のSpeakerDeckから確認できます。

speakerdeck.com

アンドパッドでは建設現場のペインを技術で解決する Swift エンジニアを大歓迎しています。

hrmos.co