Firebase + RxSwiftで社内サイネージを再現してみた

はじめに

この記事はアイスタイル アドベントカレンダー24日目の記事です。

メリークリスマスイブ。サンタが家に来なくなって10年経ちましたhayakawatです。

アイスタイルには20新卒エンジニアとして入社しました。
現在はアプリ開発グループのiOSチームに所属し、iOS版@cosmeアプリの開発に携わっています。

学生の頃からiOSアプリの開発をしていたものの、いざ業務レベルのコードに触れてみると
初めて扱う技術、アプリの仕様、GUIアーキテクチャ、システムアーキテクチャなど、分からないことがたくさんありました。そして、多くのことを学んでいるうちにあっという間に12月になってしまいました。

話は変わり12月といえば弊社にとって重要なイベントがあります。
そうです。みなさんご存知@cosme Beauty Dayです!!!

引用 : @cosme Beauty Day

@cosme Beauty Day(以下、BD)とは、弊社が運営する@cosme SHOPPING/@cosme TOKYOにて12月1日から12月4日まで
「限定スペシャルアイテムの販売」や「20%ポイントバック」などを行う、一年に一度のコスメの大規模イベントです。詳しくはこちらをご覧ください。

僕はこのBDに乗じて100枚入りフェイスパックを2セット買いましたサンタさんに貰いました!
来年のアドカレでは僕の肌の変化のまとめを書こうと思います。

社内サイネージ うりあげクン

前述した、ビッグイベントであるBDの売り上げをリアルタイムで見守る社内サイネージが3年前から開発されています。それがうりあげクンです。

うりあげクンは毎年リニューアルされており、今年のうりあげクンのフロントエンドの開発を僕が引き受けました。フロントエンドはVue.js、DBはFirebaseを用いて実現しています。

Vue.js + Firebaseについての詳しい実装方法は、先輩のnakazawayさんが2年前のアドベントカレンダーで書いているのでこちらの記事をご覧ください。

概要を説明すると
まず、BDの売り上げデータをKafkaに蓄積します。
次に、KafkaからFirebaseのRealtime Databaseにデータを渡します。
そしてVue.jsでFirebaseから売り上げの取得・表示を行います。

こちらが実際に動いているキャプチャになります。
※売り上げ、日付はダミーです

今回作るもの

前置きが長くなってしまいましたが「はじめに」で述べている通り、自分はこう見えてもiOSエンジニアの端くれです。

そこでBDに向けてVue.jsとFirebaseで開発した「うりあげクン」を業務で使っているMVVMの概念やRxSwiftを用いてiOSで開発してみようと思います。(Firebaseから値を参照して表示するだけのアプリケーションにMVVMはオーバーキル気味ですがご容赦ください。)

開発環境

開発環境は以下のようになっています。

  • Swift 5.1
  • Xcode 12.2

使用ライブラリは以下の2つです

  • RxSwift
  • Firebase

RxSwiftについて

RxSwiftのRxとはReactive Extensionsの略で、非同期処理やイベント処理を記述することができる便利なライブラリです。今回の実装ではFirebaseとの非同期処理のために利用します。

実装

MVVMについて

前述の通り、今回はMVVMというGUIアーキテクチャを用いて実装していきます。
MVVMとはModel-View-ViewModelの略でアプリケーションをView、ViewModel、Modelの3つに分割・実装する方法です。それぞれに以下のような役割があります。

M・V・VM 役割
Model ・ ビジネスロジック
・ データの処理
View ・ ユーザのアクションをViewModelへ通知
・ ViewModelの変更をViewに反映
ViewModel ・ ViewとViewModelのバインディング
・ Viewの状態を保持
・ ViewとModelの仲介

今回開発するうりあげクンiOSに当てはめるとこのようになります。

M・V・VM 役割
Model ・ Firebaseから売り上げデータを取得
View ・ ViewModelがModelから取得した売り上げデータをViewに反映
ViewModel ・ ViewとModelの仲介

準備

今回はFirebaseとRxSwiftの2つのライブラリを利用するので、CocoaPodsというライブラリ管理ツールで2つのライブラリをインストールします。

まずはView、ViewModel、Modelの3つのクラスファイルとレイアウトを作成するStoryboardファイルを作成します。そして、このようにフォルダに分けておきます。
View

  • SalesViewController.swift
  • SalesView.stroyboard

ViewModel

  • SalesViewModel.swift

Model

  • SalesModel.swift

レイアウトを作成する


画面内にUILabelを売り上げ表示用とタイトル用に2つ設置します。(AutoLayoutは割愛)
念のため背景の色は@cosmeっぽいグリーンにしておきます。

コードを書く

SalesViewController.swift

import UIKit
// ライブラリをインポートする
import RxSwift

class SalesViewController: UIViewController {
    // 売り上げ表示用のラベルの宣言
    @IBOutlet weak var salesLabel: UILabel!
    // SalesViewModelのインスタンス生成
    private let viewModel = SalesViewModel()
    // subscribeした処理を開放するために必要
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }

    func bind() {
        // ViewModel側のfetchAmountを監視
        viewModel.fetchAmount()
            .subscribe(onNext: { [weak self] response in
                self?.salesLabel.text = String(response) + "円"
            }).disposed(by: disposeBag)
    }
}

viewModelのfetchAmountを購読し、帰ってきたレスポンスを売り上げ表示用ラベルに表示します。
将来的にviewDidLoadに処理が増えることを考慮してbindメソッドに分けてあります。

SalesViewModel.swift

import RxSwift

class SalesViewModel {
    // SalesModelのインスタンス生成
    private let model = SalesModel()

    // SalesModelのfetchAmountを監視しView側へ通知
    func fetchAmount() -> Observable<Int> {
        return model.fetchAmount().asObservable()
    }
}

シンプルな機能のアプリのためViewModelの存在意義が危ぶまれるほど中身がありませんが、MVVMの練習と今後さらに機能が増えた時を想定して作ってあります。

SalesModel.swift

// FirebaseのDBを扱うためのライブラリ
import FirebaseDatabase
import RxSwift

class SalesModel {

    private var ref: DatabaseReference!
    // Firebaseから売り上げデータを取得する
    func fetchAmount() -> Observable<Int> {
        return Observable.create { [weak self] observer in
            self?.ref = Database.database().reference()
            self?.ref.child("sales").observe(.value, with: { snapShot in
                if let obj = snapShot.value as? [String : Any] {
                    let amount = obj["amount"] as! Int
                    observer.onNext(amount)
                }
            })
            return Disposables.create()
        }
    }
}

Firebaseのスキーマは以下のようになっているので

{
    "sales": {
        "amount': <long>"
    }
}

このようにして売り上げを取得します。

 self?.ref.child("sales").observe(.value, with: { snapShot in
                if let obj = snapShot.value as? [String : Any] {
                    let amount = obj["amount"] as! Int
                    observer.onNext(amount)
                }
            })

動かしてみる①

ここまでの実装でFirebaseからリアルタイムで売り上げを取得し、表示できるようになっているはずなので実際に動かして試してみます。

見事、Firebaseに入力された売り上げがリアルタイムで反映されていることが確認できました。
しかし、数字が切り替わるだけなので少し味気ないと思います。せっかくなので数字がだんだん増えていくアニメーションも実装したいと思います。そこで、以下のようなUILabelのサブクラスを実装しました。
CountUpAnimationLabel.swift

import UIKit

class CountUpAnimationLabel: UILabel {

    var startTime: CFTimeInterval!
    var fromValue: Int!
    var toValue: Int!
    var duration: TimeInterval!

    // 3桁ごとにカンマを入れる
    func addComma(amount: Int) -> String {
        let formatStyle = NumberFormatter()
        formatStyle.numberStyle = .decimal
        formatStyle.groupingSeparator = ","
        formatStyle.groupingSize = 3
        guard let formatAmount = formatStyle.string(from: NSNumber(integerLiteral: amount)) else { return "0"}
        return formatAmount
    }

    func animate(from fromValue: Int, to toValue: Int, duration: TimeInterval) {

        self.startTime = CACurrentMediaTime()

        self.fromValue = fromValue
        self.toValue = toValue
        self.duration = duration

        let displayLink = CADisplayLink(target: self, selector: #selector(update))
        displayLink.add(to: .current, forMode: RunLoop.Mode.common)
    }

    @objc func update(displayLink: CADisplayLink) {
        let durationTime = (displayLink.timestamp - startTime) / duration
        if durationTime >= 1.0 {
            self.text = addComma(amount: toValue) + "円"
            displayLink.invalidate()
            return
        }
        let currentAmount = Int(Double(toValue - fromValue) * durationTime) + fromValue
        self.text = addComma(amount: currentAmount) + "円"
    }
}

作成したサブクラスをSalesViewControllerのUILabelに反映します。
SalesViewController.swift

import UIKit
import RxSwift

class SalesViewController: UIViewController {

    // @IBOutlet weak var salesLabel: UILabel!
        ↓
    @IBOutlet weak var salesLabel: CountUpAnimationLabel!

    private let viewModel = SalesViewModel()
    private let disposeBag = DisposeBag()

    private var fetchValue: Int = 0 // 追加

    override func viewDidLoad() {
        super.viewDidLoad()
        buid()
    }

    func buid() {
        viewModel.fetchAmount()
            .subscribe(onNext: { [weak self] response in
                // self?.salesLabel.text = String(response)
                    ↓
                self?.salesLabel.animate(from: self?.fetchValue ?? 0, to: response, duration: 2.0)
                self?.fetchValue = response // 追加
            }).disposed(by: disposeBag)
    }
}

動かしてみる②

以上の実装をふまえ、改めて動かしてみたいと思います!


売り上げがカウントアップされるようになりました。また、三桁ごとにカンマが入るので数字も見やすくなったと思います。
先ほどのキャプチャに比べてうりあげクンらしくなりました!

感想

今回はVue.jsとFirebaseで実現した社内サイネージをiOSで実装してみました。

アプリの機能としてはFirebaseから値を取得して表示するだけだったので、かなりシンプルな実装になりました。しかし、SwiftとVue.js(JavaScript)で実装してみることにより言語の差異をより肌で感じることができました。

Vue.jsで実装した時は、数字をカウントアップするアニメーションがライブラリで用意されていたので1行くらいでとても楽に実装できました。
しかし、普段CSSを触らない身としてはレイアウトを構築するのにかなり苦労しました。

反対にSwiftでは、カウントアップアニメーションを実装するために、そこそこコードを書かなければ実装できませんでした。もしかしたら何かしら便利なライブラリがあるかもですが…
実は一番時間がかかったのがカウントアップアニメーションの実装でした(笑)
レイアウトはStoryboardを使って直感的に部品を配置することができるのでとても簡単でした。

アドカレを書くにあたってSwiftとVue.jsを比較し、それぞれの特性を感じることができてよかったです。
また、MVVMとRxSwiftを用いて簡単なアプリケーションを作ることにより、さらに理解が深まったかなと思います。

最後まで読んでいただきありがとうございました!