Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

HTMLとCSSでStudyplusのロゴをざっくり描いたり動かしたりする

こんにちは。ForSchool事業部の石上です。だし巻き卵が好きです。

やりたいこと

さて、今回はCSSで遊ぶだけの記事です。以前、RubyWorld Conferenceへ参加させてもらった際、自社ブースでStudyplusのロゴに付箋をはっつけてベストRuby本を投票してもらうみたいなやつをやっていたことがあります1。あれをオンラインでやれたら面白そうだなと思ったのですが、思っただけで何もしていませんでした。今回それをふと思い出したので、ロゴをCSSで描いてみようという感じです。

ロゴを見てみる

f:id:shgam:20200317163747j:plain

ロゴを見てみると、長方形と三角形、そしてそれらを傾けて並べることが必要そうです。ひとつずつやっていきます。

長方形

長方形は簡単です。widthheightbackgroundを指定するだけです。

三角形

三角形はちょっとむずかしいです。調べてみると、どうやら三角形をCSSで描くときは、border-widthを使えば良いようです。

border-width

MDNでborder-widthのページを見てみます。

border-widthに値を4つ指定すると、 border-width: 上 右 下 左の指定になるみたいです。さらにここに色をつけてみるとわかりやすいです。borderは上下左右指定すると、それぞれが台形になるんですね。

それぞれ別の色を指定すれば、きれいに三角形ができるまでの過程がわかりやすいので、アニメーションさせてみました。

See the Pen Animation of making triangle by gaaamii (@gaaamii-the-sasster) on CodePen.

それぞれのborder-widthをboxの幅の半分に指定したところ、boxの中身が全部borderで埋め尽くされ、きれいに三角形で四等分されました。なお、border-box: 0にしておかないとbox内の領域をそのまま保とうとしてしまうので、boxに大きいborderが付くだけになってしまうので注意です。

表示したい部分以外をtransparentにすることで1つの三角形にする

下のborderだけ色を付けほかは透明にすることで、色をつけたところの三角形だけを表示できます。さらに、伸ばしたい辺のborder-widthをboxと同じ長さに、かつ向かい合う辺のborder-widthを0に、それ以外の辺はboxと半分の長さにすることで、いい感じにboxの幅と同じ長さの底辺の二等辺三角形が描けました。

描いていく

長方形と三角形が描けるということは、なんだか描けそうな気がしてきました。やっていきます。

マークアップ

まずはHTMLを書きます。これでどうでしょうか。

<i class="logo">
  <span class="line"></span>  
  <span class="line"></span>  
  <span class="line"></span>  
  <span class="line"></span>  
  <span class="line"></span>  
  <span class="tip"></span>
</i>

ロゴを構成するのは5本の線と鉛筆の先でしょう。

5本の線をCSSで描く

スクリーンショット 2020-03-16 23.23.14.png (14.0 kB)

まずは、こんな感じの線を描いてみたいです。何年もStudyplusを利用したり開発に関わってきた身としては、もうすでにこれでStudyplusという感じさえします。これくらいなら自分のCSS力でもスッと書けそうです。

:root {
  --line-width: 20px;
  --line-height: 100px;
}
.line {
  margin-left: calc(var(--line-width) / 4);
  width: var(--line-width);
  height: var(--line-height);
  transition: 0.5s;
}
.line:nth-child(1) {
  background: #e74126;
  height: var(--line-height);
}
.line:nth-child(2) {
  background: #f3b418;
  height: calc(var(--line-height) * 0.8);
}
.line:nth-child(3) {
  background: #8dc32e;
  height: calc(var(--line-height) * 0.7);
}
.line:nth-child(4) {
  background: #36b397;
  height: var(--line-height);
}
.line:nth-child(5) {
  background: #2f71b7;
  height: calc(var(--line-height) * 1.2);
}

これでどうでしょうか。ここではCSS変数とcalcを使っています。プロダクションではIE 11対応が必要だったりしてSassを入れたりしてますが、早く時代が進んでCSSだけで全部できるようになるといいですね。

大きい三角形と小さい三角形を描く

スクリーンショット 2020-03-16 23.34.40.png (15.1 kB)

次に、鉛筆の先の部分を描きます。鉛筆でいう木の部分と芯の黒い部分は、今回はHTMLで1つの要素としてマークアップしてあります。

  <span class="tip"></span>

なので、::after という疑似要素にスタイルを当てて、黒い芯の部分を表現します。

:root {
  --line-width: 20px;
  --line-height: 100px;
  --tip-width: calc(var(--line-width) * 3.8);
  --tip-height: var(--line-height);
}

.tip {
  width: var(--tip-width);
  height: var(--tip-height);
  margin-left: calc(var(--line-width) / 4);
  box-sizing: border-box;
  border-style: solid;
  border-color: transparent;
  border-width: calc(var(--tip-height) / 2) 0 calc(var(--tip-height) / 2) var(--tip-width);
  border-left-color: #efdab3;
  border-radius: 5px;
}
.tip:after {
  --width: calc(var(--tip-width) / 3.5);
  --height: calc(var(--tip-height) / 3.5);
  width: var(--width);
  height: var(--height);
  display: block;
  content: "";
  position: relative;
  left: calc(var(--width) * -1);
  top: calc(calc(var(--height) / 2) * -1);
  box-sizing: border-box;
  border-style: solid;
  border-color: transparent;
  border-width: calc(var(--height) / 2) 0 calc(var(--height) / 2) var(--width);
  border-left-color: #000;
}

傾ける

スクリーンショット 2020-03-16 23.37.15.png (36.8 kB)

最後に、これらの全体を傾けます。

.logo {
  transform: rotate(40deg);
}

まとめ

よくよく見るとスタイルが雑なせいでまがいものみたいな出来になってしまいました。デザイナーの方々に見られたら怒られそうな気もします。が、とりあえずはHTMLとCSSだけでざっくりStudyplusのロゴを描くことができました。自分のCSS力では今の所これが精一杯です。.tipの部分に立体感を出したり、細かいところが難しいですね。

とりあえずCSSで表現できたことによって、好きなように動かしたりすることができるようになりました。.lineを上下にうにょうにょさせたり、全体を揺らしたりができます。これだけだと何が面白いのという感じですね。

今後はもうちょっと細部をちゃんとしつつ、JSからDOMのstyle属性をいじってデータを流し込んだりして遊んでみたいです。

See the Pen Studyplus logo drawn by CSS (with Text) by gaaamii (@gaaamii-the-sasster) on CodePen.

Kubernetes上でのFluentdを使ったログ収集について

こんにちは。ご機嫌いかがでしょうか? SREチームの栗山(id:shepherdMaster)です。 弊社ではKubernetesを導入するために着々と準備を進めております。 どんなシステム上でアプリケーションを動かすにせよ、ログ収集は必要になってきます。
Kubernetes上でログ収集をするために色々調べましたが実用的な情報があまり豊富ではなかったので、今回はKubernetes上でのログ収集、特にFluentdの設定について共有をしたいと思います。
なおまだ実運用は開始してないので今後細かい部分は変わるかもしれません。

ログ収集&ログ分析の構成

構成は以下にしました。

Fluentd + S3 + Amazon Athena

理由は以下です。

  • S3に保存すると非常に安い
  • SQLでログを検索できるのは非常に便利
  • Fluentdの設定の柔軟性
  • 既存のログ収集基盤がFluentd + S3 + Amazon Athenaになっていたため、資産の流用ができ、学習コストや管理コストも抑えられる

ログ収集ツールとしてはより軽量なFluent Bitも考えましたが、S3に保存するためのoutput pluginがなかったので諦めました。

Kubernetes上でのログ取集の課題

Kubernetes上でのログ収集を進めていくうちに課題がいくつか出てきました。
まずログ収集の全体の流れですが、コンテナの標準出力/標準エラー出力結果がホストマシンの/var/log/containers以下にファイル出力されます。そしてFluentdがそのファイルをtailし、S3に保存する流れになります。

このとき、/var/log/containers以下出力されるファイルに難があります。 具体的に/var/log/containers以下にあるログを見てみましょう。

{
  "log": "time:2020-03-11T11:09:55+00:00\thost:10.3.48.x\tvhost:health-check.studyplus.jp\tserver:deployment-7f94cc5958-bgs7g\treq:GET /health_check HTTP/1.1\turi:/health_check\tstatus:200\tmethod:GET\treferer:-\tua:kube-probe/1.14+\treq_time:0.011\tapp_revision:-\truntime:0.009058\tcache:-\tapp_time:0.008\trequest_id:9967a0a0a6486435accf64b495da67b0\tx_request_id:-\tres_size:0\treq_size:177\n",
  "stream": "stdout",
  "time": "2020-03-11T11:09:55.217300042Z"
}

logの部分にコンテナが出力したログが入っています。 ちなみに上記はnginxのログですが、nginxはLTSV形式でログ出力するようにしています。なので\tという文字がところどころ入っています。また厄介なことに末尾に\nが入ってます。
streamにはstdout(標準出力)かstderr(標準エラー)かが入ります。

このことから、

  • コンテナが出力したログをS3に保存するためには、JSONのlog値を取り出さないといけない。
  • log値の末尾の\nを削除する必要がある(じゃないとJSONとしてはinvalid扱いになる)
  • ログの中を見ないと標準出力ログなのか、標準エラー出力ログなのか分からない。

ということが分かります。 つまり、単純に /var/log/containers以下のログをそのままS3に放り込んでもAthenaでは、log値をlike検索するくらいしかできません。 それだと調査のときにログを柔軟に絞り込むことが出来なくて困るので、ログが適切な形でS3に保存されるようにFluentdの設定をする必要があります。

最終的にやりたいことを整理すると、

  • コンテナごとに保存先を変えたい
  • 標準出力ログ、標準エラーログで、保存先を変えたい
    • なぜかというとnginxはエラーログに対してログフォーマットを指定できないので、保存先を変えてエラーログはAthenaから通常のログとは別に検索したい
  • コンテナが出力したログをそのまま保存したい

FluentdをDaemonSetで動かすには

Fluentdの設定ファイルの前にまずFluentdをDaemonSetで動かします。
https://github.com/fluent/fluentd-kubernetes-daemonset で提供されているものを使うと比較的楽にDaemonSetで動かすことができます。
https://github.com/fluent/fluentd-kubernetes-daemonset/tree/master/docker-image 以下にバージョンごとにディレクトリがありますが、さらにそのディレクトリ以下をみると出力先に応じたimageが用意されています。 S3に保存したいので、debian-s3を選びました。

以下が具体的なマニフェストファイルの内容です。

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluentd-daemonset
spec:
  selector:
    matchLabels:
      name: fluentd-pod
  template:
    metadata:
      labels:
        name: fluentd-pod
    spec:
      containers:
        - name: fluentd-container
          image: fluent/fluentd-kubernetes-daemonset:v1.8-debian-s3-1
          env:
            - name: S3_BUCKET_NAME
              value: <バケット名>
            - name: FLUENTD_SYSTEMD_CONF
              value: disable
          resources:
            requests:
              cpu: 200m
              memory: 500Mi
            limits:
              memory: 1Gi
          volumeMounts:
            - name: config-volume
              mountPath: /fluentd/etc
            - name: varlog
              mountPath: /var/log
            - name: varlibdockercontainers
              mountPath: /var/lib/docker/containers
              readOnly: true
      volumes:
        - name: config-volume
          configMap:
            name: fluentd-configmap
        - name: varlog
          hostPath:
            path: /var/log
        - name: varlibdockercontainers
          hostPath:
            path: /var/lib/docker/containers

独自のfluent.confを配置するために、ConfigMapを使ってvolumeマウントをしています。こうすることで、/fluentd/etc/以下にfluent.confが配置されます。 そうしないと https://github.com/fluent/fluentd-kubernetes-daemonset/tree/master/docker-image/v1.9/debian-s3/conf 以下にあるfluent.confが使用されます。

/var/logと/var/lib/docker/containersの両方をvolumeマウントしている理由は、/var/log以下のログは/var/lib/docker/containers以下のログにシンボリックリンクがはられているので両方マウントしないとログが読み込めないためです。

FLUENTD_SYSTEMD_CONFをdisableにしているのはここにあるように不要なログを出力しないためです。

fluent.confの設定

実際には、RailsのログやFluentdのログも処理するように設定を書いてますが、今回は話をシンプルにするためにnginxのログ設定のみを書いてます。

# /var/log/containers以下には様々なコンテナのログが保存されているので、pathで該当のコンテナのログを指定します。
<source>
  @type tail
  path "/var/log/containers/*_nginx-container-*.log"
  pos_file /var/log/nginx-container.log.pos
  tag nginx
  read_from_head true
  <parse>
    @type json
    time_format %Y-%m-%dT%H:%M:%S.%NZ
  </parse>
</source>

# logの値の末尾に\nがつくので削除する
<filter nginx>
  @type record_transformer
  enable_ruby
  <record>
    log ${record["log"].strip}
  </record>
</filter>

# 標準出力と標準エラーでtagを分ける
<match nginx>
  @type rewrite_tag_filter
  <rule>
      key     stream
      pattern /^stdout$/
      tag     "${tag}.stdout"
  </rule>
  <rule>
      key     stream
      pattern /^stderr$/
      tag     "${tag}.stderr"
  </rule>
</match>

# jsonのlog値にコンテナが出力ログが入っているのでを取り出す
<filter nginx.stdout>
  @type parser
  key_name log
  <parse>
    @type ltsv
    keep_time_key true
    types status:integer, req_time:float, runtime:float, app_time:float, res_size:integer, req_size:integer
  </parse>
</filter>

<filter nginx.stderr>
  @type parser
  key_name log
  <parse>
    @type none
  </parse>
</filter>

# S3に保存する
<match nginx.stdout>
  @type s3
  format json
  s3_bucket "#{ENV['S3_BUCKET_NAME']}"
  s3_region ap-northeast-1
  s3_object_key_format "${tag[0]}-log/%{time_slice}/${tag[0]}-%{index}.log.%{file_extension}"
  time_slice_format year=%Y/month=%m/day=%d/hour=%H
  <buffer tag,time>
    @type file
    path "/var/log/fluentd-buffers/s3.buffer"
    timekey 3600
    timekey_wait 10m
    timekey_use_utc true
    chunk_limit_size 1G
    flush_at_shutdown true
  </buffer>
</match>

<match nginx.stderr>
  @type s3
  format single_value
  s3_bucket "#{ENV['S3_BUCKET_NAME']}"
  s3_region ap-northeast-1
  s3_object_key_format "${tag[0]}-error-log/%{time_slice}/${tag[0]}-error-%{index}.log.%{file_extension}"
  time_slice_format year=%Y/month=%m/day=%d/hour=%H
  <buffer tag,time>
    @type file
    path "/var/log/fluentd-buffers/s3-error.buffer"
    timekey 3600
    timekey_wait 10m
    timekey_use_utc true
    chunk_limit_size 1G
    flush_at_shutdown true
  </buffer>
</match>

ちなみに、fluent.conf内で使えるfluentd pluginは https://github.com/fluent/fluentd-kubernetes-daemonset/blob/master/docker-image/ 以下にあるGemfile(たとえばこれ)内に定義されているものが使えます。

おまけ

Fluentdの設定ファイルを書いていると、すぐにログをflushしてS3に保存し、保存されたログファイルの中身を確認したいケースがでてきます。そういうときは、USR1 signalを送ると強制的にflushしてくれます。 たとえば以下のようなワンライナーを用意しておくと便利です。

kubectl exec `kubectl get pod -o name | grep fluentd` -- /bin/sh -c "pkill -USR1 -f fluentd"

まとめ

Kubernetes上で動くアプリケーションのログ収集のために、FluentdのDaemonSetリソースファイルとfluent.confの紹介をしました。 Dockerが出力する癖のあるログによって苦戦しましたが、Fluentdの柔軟さによって助けられました。

それでは、みなさん良きログ収集ライフを。

AWS Lambdaを使ったStudyplus for SchoolのLINE連携

こんちにちは、ForSchool事業部の島田です。

今回はStudyplus for School(以下FS)のLINE連携について紹介させていただきます。

LINE連携とは?

LINEの「FS公式アカウント」と生徒の保護者が友だちになることで保護者と塾(講師)が連絡をとれたり、生徒(子供)の塾への入退室情報や勉強の状況を共有できる機能です。

LINE連携でできること

  • 保護者が
    • 塾とメッセージのやりとりが出来る
    • 生徒の塾の入室・退室のお知らせを受信できる
  • 塾が保護者へ
    • 指導報告や面談報告を送信できる
    • 生徒の学習記録を送信できる

LINE連携のフロー概要

LINEとのメッセージの送受信には、Messaging APIを使っています。

developers.line.biz

LINE連携でのメッセージをやりとりするフローの概要は以下のようになります。

  1. LINEと連携するためのステップ(最初の1度のみ)
  2. 保護者へのメッセージの送受信
  3. 保護者からのメッセージの送信

f:id:yo-shimada:20200220151245p:plain

システム構成について

FSとLINEとのやりとりをする実装には AWS Lambda + API Gateway を利用しています。 外部に公開するURLはできるだけFSシステムと疎結合にしておき、LINEとのやりとりはLambdaに責任を持たせて、FSのシステムではなるべく関知しないようにしたいという意図があります。

f:id:yo-shimada:20200210195854p:plain

Lambda関数について

Lambdaに2つの関数を実装しました。説明の便宜上/webhook/messages と記載します。

  • /webhook:LINEからのwebhookを受けるエンドポイント。LINE上でFS公式アカウントに対してイベントが起きたときにこのエンドポイントでPOSTリクエストを受けます。
  • /messages:FSからメッセージを送信する際に受けるエンドポイント。

関数の実装について

Lambdaは、FSがRuby on Railsを利用している観点から「Ruby 2.5」を利用しています。
LINE APIとのやりとりにはLINE Messaging API SDK for Rubyを利用しています。

github.com

以下は実装のイメージを掴んでいただくための概略したコードです。
それぞれpost_handlerがLambdaのハンドラー関数になります。

webhook.rb

require 'json'
require 'line/bot'
require 'net/https'
require 'uri'

# LINEにメッセージがあった場合にwebhookから呼び出される関数
def post_handler(event:, context:)
  signature = event["headers"].fetch("X-Line-Signature")
  body = event["body"]

  raise "LINEの署名が不正です" unless client.validate_signature(body, signature)

  events = client.parse_events_from(body)
  events.each do |event|
    case event
    when Line::Bot::Event::Message
      case event.type
      when Line::Bot::Event::MessageType::Text
        line_user_id = event["source"]["userId"]
        # 初めて連携をする場合にFSから発行した連携コードをメッセージしてもらう
        if line_code?(event.message["text"])
          line_code = event.message["text"]
          # FS側に連携コードとLINE UserIDを渡して、FSの生徒と保護者のLINEを紐づける
          connect(line_code, line_user_id, reply_token: event["replyToken"])
        else
          # LINEメッセージの受信。保護者から来たメッセージをFSに渡し塾が確認できるようにする
          message = event.message["text"]
          send_fs_message(message, line_user_id)
        end
    when Line::Bot::Event::Unfollow
      # FS公式アカウントをブロックした場合
      disconnect(event["source"]["userId"])
    when Line::Bot::Event::Postback
      # FlexMessageを利用して場合に利用
      ...
    end
  end

  { statusCode: 200, body: JSON.generate('ok') }
rescue => e
  puts e, e.backtrace
  { statusCode: 400, body: JSON.generate("Bad Request") }
end

def client
  @client ||= Line::Bot::Client.new do |config|
    config.channel_secret = LINE_CHANNEL_SECRET
    config.channel_token = LINE_CHANNEL_TOKEN
  end
end

def connect(line_code, line_user_id, reply_token:)
  res = post(URI.join(FS_URL, CONNECT_PATH), {
    code: line_code,
    line_user_id: line_user_id
  })
  case res.code
  when "200"
    send_line_message("#{student_name}さん」と連携しました!", reply_token: reply_token)
  when "404"
  ...
  end
end

# FSにメッセージを送信
def send_fs_message(message, line_user_id)
  post(URI.join(FS_URL, MESSAGE_PATH), {
    line_user_id: line_user_id,
    message: message,
  })
end

# LINEにメッセージを送信
def send_line_message(message, reply_token:)
  client.reply_message(reply_token, {
    type: "text",
    text: message
  })
end

messages.rb

require 'json'
require 'line/bot'

# FSからのメッセージ受けてをLINEへ送信する関数
def post_handler(event:, context:)
  body = JSON.parse(event["body"])

  # メッセージとLINE UserIDを取得
  message = body["message"]
  line_user_id = body["line_user_id"]

  # LINEにメッセージを送信
  result = client.push_message(line_user_id, message)
  if result.all? { |res| res.code.start_with?('20') }
    { statusCode: 200, body: JSON.generate('success') }
  else
    response_body = result.map { |res| { code: res.code, body: res.body } } 
    { statusCode: 400, body: JSON.generate(response_body) }
  end
end

def client
  # webhook.rb と同じ 
end

最後に

スタディプラスでは、前例がなくても要件を実現するためには新しいサービスやツールを積極的に取り入れています。
ForSchool事業部でLambdaを採用することは今回が初めてでしたが、大きな問題なく開発・運用ができています。
今後も外部との連携やシステムを疎結合にしていく際に、この事例を参考に必要なサービスを利用していこうと考えています。

LINEでのやりとりのキャプチャー f:id:yo-shimada:20200219144120j:plain

redux-thunkを使っているプロジェクトでのAPIリクエストの競合をAbortControllerで素朴に解決する

こんにちは。ForSchool事業部の石上です。今年の抱負はラーメンを月2食に抑えることです。今の所はなんとか達成できております。

さて今回は、Studyplus for School(以下、社内での呼び方でFSと書きます)のフロントエンドで、どうやってAPIリクエストの競合を回避したかという話について書きます。

背景

FSのフロントエンドには、非同期の処理をするためにredux-thunkを使っています。

Reduxで非同期処理といえばredux-sagaとredux-thunkどっちを使うのか、というのがよく話題に上がると思います。FSでのredux-thunkの採用理由は単純で、使い方をすぐ理解できるからでした。結果としてactionに非同期処理が入ってくることによる苦しみを味わうことになったのですが、その話はまた別でしたいと思います(今回の話もその一部です)。

FSのフロントエンドはシングルページアプリケーション(以下、SPA)です。HTMLを毎回ダウンロードするのではなく、必要なデータを必要なときにAPIから取得して、画面の特定の部分を更新します。

そのため何も考えずに実装をすると、うっかり間違った画面を表示することになります。まずはその問題について簡単に、なるべく具体的な例で書いていきます。

検索状態に対して画面に表示される結果が合わなくなる可能性

FSには、生徒一覧を表示する画面があります。この画面はとても一般的な機能を持っていて、検索条件を指定すると画面が更新されて、それにマッチする生徒が表示されます。

SPAでなければ、検索条件のクエリパラメータをもとにSQLで生徒一覧を取得、それをHTMLに埋め込んで表示という流れになるかと思います。

SPAの場合は、検索条件のクエリパラメータをつけたAPIのURLへリクエストを投げ、その結果を画面に表示します。基本的にはSPAでない場合と変わりはないですね。

ただ、気をつける必要があるのはその結果の反映順序です。APIへのリクエストとレスポンスは、工夫をせずに行うと、リクエストした順番とは違う順番でレスポンスを処理する可能性があります。

今回の例で考えてみます。高校生のタグをつけられた生徒を取得するリクエストの直後に、中学生のタグをつけられた生徒を取得するリクエストをしたとします。工夫をせずにただリクエストを投げた場合、選択したタグは高校生なのに表示されるのは中学生の生徒一覧、ということが起きうるのです。

検索条件と結果がちぐはぐになってしまった場合のイメージ
検索条件と結果がちぐはぐになってしまった場合のイメージ

解決方法

うちの場合、AbortControllerというブラウザの機能を利用してこの問題を回避しています1。Abortとは中断という意味の英単語なので、中断制御するやつという感じですね。機能もまさにその名のとおりです。

使い方は簡単で、このMDNのリンクに書かれている例の通りです。これにコメントを書き加えると以下のような感じです。

// AbortControllerを生成
var controller = new AbortController();
var signal = controller.signal;
var downloadBtn = document.querySelector('.download');
var abortBtn = document.querySelector('.abort');
downloadBtn.addEventListener('click', fetchVideo);
// 中断ボタンをクリックすると
abortBtn.addEventListener('click', function() {
  // リクエストを中断する
  controller.abort();
  console.log('Download aborted');
});
function fetchVideo() {
  ...
  // fetchの引数にAbortControllerのsignalを指定
  fetch(url, {signal}).then(function(response) {
    ...
  }).catch(function(e) {
    reports.textContent = 'Download error: ' + e.message;
  })
}

これをredux-thunkの中で使うために、以下のような実装にしました。

  1. APIリクエストの処理を担当するクラスをつくる
  2. そのクラスに、AbortControllerも管理させる
  3. interruptGetというメソッドを生やして、そのメソッドでAPIを叩いたときは、競合するリクエストを中断してからリクエストを投げるようにする
  4. AbortErrorはキャッチして無視する(エラー表示などはしないようにしておく)
client.interruptGet('/api/hoge')
  .then(res => {
    dispatch(getHogeSuccess(res))
  })
  .catch(err => {
    if (err.name === 'AbortError') {
      return
    }
    dispatch(getHogeError(err))
  })

ライブラリを入れず素朴に実装したつもりが、初見の人にはやや実装がわかりにくくなってしまった感もあります。ただ、これを利用したリクエストの挙動をブラウザで見てみると、やっていることはわかりやすいはずです。以下は検索条件のタグを2つ指定している状態から、ががっと2回のクリックでタグを外した様子です。下に見えているのがChromeのNetworkタブで、ここに発生したAPIリクエストが表示されています。

1回目のクリックで生徒APIへのリクエストを投げようとしますが、すぐ次のクリックによってそれが中断され、Statusがcanceledになっています。上記したinterruptGetでAPIを叩くと、必ずそれ以前の同APIへのリクエストをキャンセルするようになっているため、画面に反映されるのは最後に投げたリクエストのレスポンスとなります。

その他のアプローチ

以下のようなアプローチもあるかと思います。

thunkの中で状態を見て、1つ前のリクエストの処理が完全に終わるまで次のリクエストを投げないようにする

このやり方は、Redux作者のDan Abramov氏のスクリーンキャストで紹介されています。

実はこのスクリーンキャストはこの記事を書いているときに知りまして、観てみたらAbortControllerをつかった実装よりもよさそうだと感じました。今回紹介した画面を今後リファクタリングする際には、画面のstateの正規化をした上でこの方法を採用したいと思います。

takeLatestという関数があるらしい

redux-sagaを使っているのであれば、これが使えそうです。FSではredux-thunkを利用しているのですが、この問題のためにredux-sagaへ乗り換えるということはしませんでした。

まとめ

素朴にやってみたものの、thunk側でAbortErrorをキャッチして無視しないといけない不便さもあります。今後はそういった約束事を意識しないでも、正しい状態を保てるようなつくりへとリファクタリングしていくことが必要だと感じています。


  1. 対応ブラウザによって、polyfillが必要です。

WorkManager とViewModelの間でデータを受け渡しした話

こんにちは、モバイルクライアントグループの中島です。

今回はWorkManagerを使った非同期処理で、WorkManagerとViewModelの間でデータのやりとりを行なう方法について話したいと思います。

ここで「データのやりとり」と言っているのは、WorkManagerに処理をリクエストするViewModelとWorkManager内で実際に処理を行なうWorkerクラス間のデータ受け渡しを指します。

なお、執筆時に利用している WorkManager のバージョンは2.3.0です。

Studyplus AndroidアプリにおけるWorkManagerの導入については、下記をご参照ください。

tech.studyplus.co.jp

やりたいこと

具体的にやりたいことはこんな感じです。

  • TODO1: ViewModel -> Worker

    • Workerで行なう処理のためにパラメータを渡したい
  • TODO2: Worker -> ViewModel

    • Workerで行なった処理の結果をViewModelに返したい
class MyViewModel @Inject constructor(
    private val workManager: WorkManager
) : ViewModel() {

    fun request(data: String) {
        // TODO 1: ここでWorkerにデータを渡したい
        val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder<MyEventWorker>().build()
        workManager.enqueue(request)
        // TODO 2: ここでWorkerの実行結果を処理したい
    }
}

Workerですが、Kotlin Coroutinesを使ったCoroutineWorkerが用意されていますので、Studyplus ではそれを利用しています。 CoroutineWorker の実行メソッドは suspend function のdoWork() です。

class MyEventWorker @AssistedInject constructor(
    @Assisted private val appContext: Context,
    @Assisted private val params: WorkerParameters
) : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        // TODO 1': ここでViewModelからのデータを扱いたい
    
        return runCatching { 
            // サーバのAPI呼び出しなど
        }.fold(
            // TODO 2': ここからViewModelへ結果を返したい
            onSuccess = { Result.success() },
            onFailure = { Result.failure() }
        )
    }
}

やり方

公式ドキュメントに倣います。

developer.android.com

Workerとのデータのやり取りにはandroidx.work.Dataクラスを用います。 このクラスはデータをMapで保持しています。

  • ViewModel -> Worker

    • WorkRequest のBuilderに用意されている setInputData(@NonNull Data inputData) で渡します
    • WorkerのinputDataから取得します
  • Worker -> ViewModel

    • Resultを返す際にDataを渡します
    • getWorkInfoByIdLiveData(request.id) メソッドを用いてWorkInfoクラスのLiveDataを取得します
const val REQUEST_DATA_MAP_KEY = "request_data_map_key"
const val RESULT_DATA_MAP_KEY = "result_data_map_key"

// 実行結果を受け取るLiveData
val workResultLiveData = MediatorLiveData<String>()

fun request(data: String) {
    // TODO 1: ここでWorkerにデータを渡したい -> setInputData(Data)
    // Data作成
    val requestData = workDataOf(
        REQUEST_DATA_MAP_KEY to data
    )
    val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder<MyEventWorker>()
        .setInputData(requestData) // Dataを添付
        .build()
    workManager.enqueue(request)
    // TODO 2: ここでWorkerの実行結果を処理したい -> getWorkInfoByIdLiveData(request.id)
    workInfoLiveData.addSource(workManager.getWorkInfoByIdLiveData(request.id)) { info ->
        // 処理が終わった時に処理する場合はisFinished
        if (info.state.isFinished) {
            // info.outputDataで Data を受け取れる
            workResultLiveData.value = info.outputData.getString(RESULT_DATA_MAP_KEY)
        }
    }
}
override suspend fun doWork(): Result {
    // TODO 1': ここでViewModelからのデータを扱いたい -> inputData.get~~
    val requestData = inputData.getString(REQUEST_DATA_MAP_KEY)

    return runCatching { 
        // サーバのAPI呼び出しなど
    }.fold(
        // TODO 2': ここからViewModelへ結果を返したい -> Result.~~(data)
        onSuccess = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "success"
            )
            Result.success(resultData)
        },
        onFailure = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "failure"
            )
            Result.failure(resultData)
        }
    )
}

これでデータの受け渡しを行えます。

さらにやりたいこと

Data でプリミティブな型以外を受け渡しする

Data クラスにはBuilder処理をラップした拡張関数である workDataOf が用意されています。

androidx.work.Data.kt から抜粋

/**
 * Converts a list of pairs to a [Data] object.
 *
 * If multiple pairs have the same key, the resulting map will contain the value
 * from the last of those pairs.
 *
 * Entries of the map are iterated in the order they were specified.
 */
inline fun workDataOf(vararg pairs: Pair<String, Any?>): Data {
    val dataBuilder = Data.Builder()
    for (pair in pairs) {
        dataBuilder.put(pair.first, pair.second)
    }
    return dataBuilder.build()
}

このパラメータを見る限り、 Pair<String, Any?> で一見なんでも入れられるように見えます。 なので私は最初、通常のデータクラスを入れるコードを書いたのですが実行したところクラッシュしました。 関数内で使われているput()メソッドについて、Data.Builderクラス本体の実装を追ってみます。

androidx.work.Data.java から抜粋

/**
 * Puts an input key-value pair into the Builder. Valid types are: Boolean, Integer,
 * Long, Float, Double, String, and array versions of each of those types.
 * Invalid types throw an {@link IllegalArgumentException}.
 *
 * @param key A {@link String} key to add
 * @param value A nullable {@link Object} value to add of the valid types
 * @return The {@link Builder}
 * @hide
 */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public @NonNull Builder put(@NonNull String key, @Nullable Object value) {
    if (value == null) {
        mValues.put(key, null);
    } else {
        Class<?> valueType = value.getClass();
        if (valueType == Boolean.class
                || valueType == Byte.class
                || valueType == Integer.class
                || valueType == Long.class
                || valueType == Float.class
                || valueType == Double.class
                || valueType == String.class
                || valueType == Boolean[].class
                || valueType == Byte[].class
                || valueType == Integer[].class
                || valueType == Long[].class
                || valueType == Float[].class
                || valueType == Double[].class
                || valueType == String[].class) {
            mValues.put(key, value);
        } else if (valueType == boolean[].class) {
            mValues.put(key, convertPrimitiveBooleanArray((boolean[]) value));
        } else if (valueType == byte[].class) {
            mValues.put(key, convertPrimitiveByteArray((byte[]) value));
        } else if (valueType == int[].class) {
            mValues.put(key, convertPrimitiveIntArray((int[]) value));
        } else if (valueType == long[].class) {
            mValues.put(key, convertPrimitiveLongArray((long[]) value));
        } else if (valueType == float[].class) {
            mValues.put(key, convertPrimitiveFloatArray((float[]) value));
        } else if (valueType == double[].class) {
            mValues.put(key, convertPrimitiveDoubleArray((double[]) value));
        } else {
            throw new IllegalArgumentException(
                    String.format("Key %s has invalid type %s", key, valueType));
        }
    }
    return this;
}

Puts an input key-value pair into the Builder. Valid types are: Boolean, Integer, Long, Float, Double, String, and array versions of each of those types. Invalid types throw an {@link IllegalArgumentException}.

Boolean, Integer, Long, Float, Double, String 及びそれらのArrayのみ受け付けていることがわかります。 ドキュメントを参照すると、こちらにも明記されていますね。

ですので、プリミティブ型以外のデータクラスなどを受け渡したい場合は、JSON文字列にしてStringで受け渡しを行ないました。 SerializablePercelable も受け付けていないのは少々意外でしたが、今後追加されたら便利そうですね。

エラーハンドリングと進捗管理をする

Workerの処理が終わった時に処理を行いたいのであれば、doWork()の返り値を受け取り、isFinished()で確認して終了時のみ処理すれば十分です。

ですが、例えばエラー時のみの処理を実装したい、処理の進捗を取得したいなどの場合もあるかと思います。 その際には直接 workInfo.state で分岐してやりましょう。

進捗処理については 2.3.0-alpha1 から機能が追加されています。

developer.android.com

doWork()内でWorkerのsetProgressAsync(Data)を呼ぶことで、任意の箇所からDataを渡すことができます。 このDataWorkInfo.State.RUNNING ステータスとともに流れてきますので、その分岐の中で受け取ります。

const val REQUEST_DATA_MAP_KEY = "request_data_map_key"
const val RESULT_DATA_MAP_KEY = "result_data_map_key"
const val PROGRESS_DATA_MAP_KEY = "progress_data_map_key"

// 実行結果を受け取るLiveData
val workResultLiveData = MediatorLiveData<String>()

// 進捗状態を受け取るLiveData
val workProgressLiveData = MediatorLiveData<Int>()

fun request(data: String) {
    // Data作成
    val requestData = workDataOf(
        REQUEST_DATA_MAP_KEY to data
    )
    
    val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder<MyEventWorker>()
        .setInputData(requestData) // Dataを添付
        .build()
    workManager.enqueue(request)
    workInfoLiveData.addSource(workManager.getWorkInfoByIdLiveData(request.id)) { info ->
        // stateで分岐
        when (info.state) {
            WorkInfo.State.RUNNING -> {
                // 進捗処理、進捗はここで受け取る。info.outputDataではなくinfo.progress
                workProgressLiveData.value = info.progress.getInt(PROGRESS_DATA_MAP_KEY)
            },
            WorkInfo.State.SUCCEEDED -> { 
                // 成功時処理(isFinishedに含まれる)
                workResultLiveData.value = info.outputData.getString(RESULT_DATA_MAP_KEY)
            },
            WorkInfo.State.FAILED -> { 
                // エラー時時処理(isFinishedに含まれる)
                workResultLiveData.value = info.outputData.getString(RESULT_DATA_MAP_KEY)
            },
            WorkInfo.State.CANCELLED -> { 
                // 処理のキャンセル時処理(isFinishedに含まれる)
                workResultLiveData.value = "CANCELLED"
            },
            else -> {
                // ここでは説明しませんが、他にENQUEUEDとBLOCKEDがあります
            }
        }
    }
}
override suspend fun doWork(): Result {
    val requestData = inputData.keyValueMap[REQUEST_DATA_MAP_KEY] as? String

    val progressDataStart = workDataOf(
        PROGRESS_DATA_MAP_KEY to 0
    )
    setProgressAsync(progressDataStart)

    // ~~ 何かしら時間のかかる処理など
    // 50%
    val progressDataHalf = workDataOf(
        PROGRESS_DATA_MAP_KEY to 50
    )
    setProgressAsync(progressDataHalf)

    // ~~
    
    val progressDataEnd = workDataOf(
        PROGRESS_DATA_MAP_KEY to 100
    )
    setProgressAsync(progressDataEnd)

    return runCatching { 
        // サーバのAPI呼び出しなど
    }.fold(
        onSuccess = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "success"
            )
            Result.success(resultData)
        },
        onFailure = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "failure"
            )
            Result.failure(resultData)
        }
    )
}

最後に

今回は、WorkManager を使った非同期処理とのデータのやり取りについてお話しいたしました。

WorkManager は主にバックグラウンドの処理に使うものですが、結果をViewに反映したいケースなどもカバーされておりとても便利に使えるものだと思います。 今後もリリース動向にも注目していきたいですね。

JetpackのNavigationで開始画面を変更する方法

こんにちは、モバイルクライアントグループの隅山です。 去年からNavigationを導入してきましたが、導入する際に画面遷移周りで課題があったのでその課題について紹介します。

画面遷移の課題

スタディプラスのAndroidアプリはマルチモジュール構成となっているため、Navigationは機能モジュールごとに導入する方針としています。 Navigationを導入する上で、遷移元によって異なる画面を表示する機能の場合、どう実装するのかが課題として浮上しました。

一例としてユーザーが交流するコミュニティ機能をみてみます。この機能に遷移してくるパターンが以下の4つあり、それぞれコミュニティの最初の表示画面が異なります。

  • トップ画面→コミュニティ検索画面
  • ユーザ情報画面→コミュニティ一覧画面
  • 通知画面→コミュニティ詳細画面
  • 通知画面→コミュニティトピック詳細画面

それぞれ開始画面が異なる場合にNavigationでどう実装するか3つの方法を紹介します。

解決策1:StartDestinationを用いる

まず、一つ目にStartDestinationを用いる場合です。

StartDestinationとはNavGraphクラスの必須パラメータの一つです。このプロパティは開始画面を示しています。 NavGraphにsetStartDestination(@IdRes startDestId: Int)で開始画面を変更可能となっているため、下記のように変更することができます。

val navController = findNavController(R.id.nav_host_fragment)
val navGraph = navController.navInflater.inflate(R.navigation.community_nav_graph)

navController.graph = navGraph.apply {
    // StartDestinationで開始画面指定
    startDestination = R.id.communitySearchResultFragment
}

StartDestinationを用いる場合のメリットは、コード上で簡単に設定でき、nav_graph.xmlを変更する必要がない点です。 このため、設計にかかわらずすぐに導入したい場合に有効です。

使い所 メリット デメリット
開始画面が多い場合 nav_graph.xmlを変更する必要がない 特になし

解決策2:GlobalActionを用いる

続いて、二つ目はGlobalActionを用いる場合です。

GlobalActionとは同一NavGraph内であればどこからでも利用できる遷移のことです。 GlobalActionで最初に表示したい画面へ遷移することによって開始画面を変更することが可能です。詳しいコードは以下のようになります。

<!-- GlobalAction作成 -->
<action
    android:id="@+id/actionToSearchResult"
    app:destination="@id/communitySearchResultFragment" />
val navController = findNavController(R.id.nav_host_fragment)

// GlobalActionで開始画面に遷移
navController.navigate(
    ActionOnlyNavDirections(R.id.actionToSearchResult)
)

GlobalActionは汎用的な画面へ遷移する場合に効果的な機能です。そのため、GlobalActionを用いる方法はそのような画面が開始画面の場合に有効です。 ただ、GlobalActionを多用しすぎると、GUI上で遷移関係が追いにくくなってしまうため注意が必要です。

使い所 メリット デメリット
汎用的な画面への遷移の場合 遷移が複雑でも遷移図がシンプル GUI上で遷移関係が追いにくい

GlobalActionを用いる場合の注意

※NavGraphのstartDestinationへ遷移した直後にGlobalActionで指定位置へ遷移しているため、UI的には開始画面が変更できているが、コード上は2回の遷移が起きています。 そのため、GlobalActionの遷移先から戻った場合、NavGraphのstartDestinationに戻ります。

解決策3:NavGraphの分割

最後に、NavGraphを分ける場合です。

NavGraphの分割では開始画面ごとにNavGraphを切り分けて、最初に表示したい画面のNavGraphをNavControllerに設定することで開始画面を変更できます。 分割したNavGraphはNestedGraphを用いることでNavGraphを跨いだ遷移が実現できます。

NestedGraphの説明は今回の内容と少し外れてしまうため、説明を割愛させていただきます。

<!-- community_nav_graph_1.xml -->
<fragment
    android:id="@+id/communitySearchFragment"
    android:name="xxx.xxx.CommunitySearchFragment">

    <!-- NestedGraphで遷移するアクション -->
    <action
        android:id="@+id/actionToSearchResult"
        app:destination="@+id/community_nav_graph_2" />

</fragment>

<include app:graph="@navigation/community_nav_graph_2" />
<!-- community_nav_graph_2.xml -->
<fragment
    android:id="@+id/communitySearchResultFragment"
    android:name="xxx.xxx.CommunitySearchResultFragment" />
val navController = findNavController(R.id.nav_host_fragment)

// 開始したい画面のNavGraphを設定
if (isStart1) {
    navController.setGraph(R.navigation.community_nav_graph_1)
} else {
    navController.setGraph(R.navigation.community_nav_graph_2)
}

NavGraphの分割のメリットは、NavGraphスコープのViewModelでデータ共有できることです。 デメリットはNavGraphが分割されるため、GUI上で機能全体の遷移が追いにくい点です。

使い所 メリット デメリット
NavGraphViewModelでデータ共有したい場合 データの受け渡しが不要 GUI上で機能全体の遷移が追いにくい

採用した解決策

- 開始地点を変更 適した設計
StartDestination 実装可能 どんな設計でも有効
GlobalAction 実装可能(BackStackに注意) 汎用的な画面から開始したい場合
NavGraphの分割 実装可能 ViewModelでデータ共有したい場合

スタディプラスのコミュニティ機能では、解決策3のNavGraphの分割を採用しました。

開始地点を変更するだけならどの解決策でも実装可能なのですが、NavGraphの分割のメリットであるNavGraphViewModelを利用するためです。 コミュニティ画面ではサーバからコミュニティデータ取得して各画面でそのデータを表示することが多く、できるだけデータ取得回数を減らすためにViewModelで共有しました。

今回3つの方法を紹介しましたが、どれを採用すべきかは設計次第であるためどれがいいとは一概に言えません。 現に、スタディプラスのAndroidアプリ内では機能ごとに採用している解決策が異なっています。 どの解決策にすべきかは設計と実際に実装してみてご検討ください。

終わりに

Navigationで画面遷移を導入する際の課題を紹介しましたが、上記の事例以外では画面遷移で詰まることなく、むしろ画面遷移がGUIで簡単に実装することができました。

これからアプリを作る方はもちろん、大規模なアプリの開発に携わっている方も機能ごとや部分部分に分けて導入可能なので、是非導入してみてください。

Nuke + UIImageViewでいい感じにURLを読み込ませたい!

お久しぶりです。 モバイルクライアントグループの若宮(id:D_R_100)です。

もともとはAndroidアプリ専任だったのですが、昨年11月ごろよりiOSアプリ開発にも参加するようになりました。 今回は、iOSアプリに参加して取り組んでいたNukeによる画像読み込み処理改善についてまとめてみます。

画像の読み込み事情

Studyplusのクライアントアプリは画像の表示箇所が非常に多いアプリです。 ユーザーアイコンや特集記事のアイキャッチ、連携しているアプリアイコンなど至る所に画像が存在しています。

これらの画像は、それぞれのタイミングでSteakと呼ばれる社内システムから取得しています。 サーバー側の実装については、過去のエントリをご参照ください。

tech.studyplus.co.jp

Studyplus iOSアプリの歴史は古いため、もともと画像の読み込み処理を自前で実装していました。 また、その名残で画面サイズに応じたリサイズ済みの画像をViewごとにリクエストしていました。

こういった事情の中、iOSアプリ開発に余剰な戦力として参加することになりました。 AndroidではGlideを利用していた経験などもあったため、"お試し"で入っていたNukeをアプリ内で全面採用できるよう対応を進めるタスクに取り組みました。

Nuke + スタディプラスiOSアプリ

NukeはiOSアプリ開発で非常に人気のあるライブラリです。

github.com

余談となりますが、今回Nukeについていろいろと調べている時に下記のようなOSSライブラリを比較するページを見つけました。 有名ライブラリの活発さを比較できるのは面白いですね。 下記はKingfisherとNukeの比較ですが、単にGithubのリポジトリを見比べても気付きにくいところが比較できるので、選択のしやすさが高いなと感じます。

ios.libhunt.com

開発方針

開発を行うにあたりiOSのライブラリや既存コード、Androidライブラリの知見などを組み合わせて下記の方針を立てました。

  1. デフォルト画像を用意する側が意識せずとも、メモリに優しい実装とする
  2. イニシャライザの引数で、Viewを構成する要素を与え余計な変更の余地を残さない
  3. JSONのパラメーターをパースして渡すことになるため、nil許容で期待する動作を実現する
  4. enumを利用し、Xcode上で簡単に呼び出しができるようにする

デフォルト画像については、iOSの UIImage のドキュメントを確認したところ named 引数をとるイニシャライザを利用することで自動的に管理されることがわかりました。 このため named 引数をとる UIImage を使いやすくすることで、メモリに優しい処理が自然と利用できるようにしました。

https://developer.apple.com/documentation/uikit/uiimage

Use the init(named:in:compatibleWith:) method (or the init(named:) method) to create an image from an image asset or image file located in your app’s main bundle (or some other known bundle). Because these methods cache the image data automatically, they are especially recommended for images that you use frequently.

それ以外の方針については、次の節からコードを参考に説明していきたいと思います。

Nukeを呼び出すexクラスの作成

スタディプラスiOSアプリでは、アプリ内で利用する拡張関数を作成する際、Extensionに名前空間を作成しています。 これは意図しない関数の上書きを防ぐことや、コードの可読性をあげることを目的にしています。

この手法は下記のような記事を参考に取り入れているものです。

techblog.zozo.com

qiita.com

この手法に則り、今回は ex.loadUrl と呼び出せるよう関数を追加しました。 合わせて、アプリ内で画像のリサイズモードをOSに(そこまで)依存せずに呼び出せるよう ProcessorsOption というenumを作成しています。

public enum ProcessorsOption {
    case resize
    case resizeRound(radius: CGFloat)
    case resizeCircle
}

public typealias AspectMode = ImageProcessor.Resize.ContentMode

public extension Extension where Base == UIImageView {
    
    func loadUrl(imageUrl: String?,
                 processorOption: ProcessorsOption = ProcessorsOption.resize,
                 aspectMode: AspectMode = .aspectFill,
                 crop: Bool = false,
                 defaultImage: UIImage? = nil,
                 contentMode: UIView.ContentMode? = nil) {
        loadUrl(imageUrl: imageUrl,
                processorOption: processorOption,
                aspectMode: aspectMode,
                crop: crop,
                placeHolder: defaultImage,
                failureImage: defaultImage,
                contentMode: contentMode)
    }
    
    func loadUrl(imageUrl: String?,
                 processorOption: ProcessorsOption = ProcessorsOption.resize,
                 aspectMode: AspectMode = .aspectFill,
                 crop: Bool = false,
                 placeHolder: UIImage? = nil,
                 failureImage: UIImage? = nil,
                 contentMode: UIView.ContentMode? = nil) {
        guard let url: String = imageUrl else {
            base.image = failureImage
            return
        }
        guard let loadUrl: URL = URL(string: url) else {
            base.image = failureImage
            return
        }
        
        let resizeProcessor = ImageProcessor.Resize(size: base.bounds.size, contentMode: aspectMode, crop: crop)
        let processors: [ImageProcessing]
        switch processorOption {
        case .resize:
            processors = [resizeProcessor]
        case .resizeRound(let radius):
            processors = [resizeProcessor, ImageProcessor.RoundedCorners(radius: radius)]
        case .resizeCircle:
            processors = [resizeProcessor, ImageProcessor.Circle()]
        }
        
        let request = ImageRequest(
            url: loadUrl,
            processors: processors
        )
        var contentModes: ImageLoadingOptions.ContentModes?
        if let mode = contentMode {
            contentModes = ImageLoadingOptions.ContentModes.init(success: mode, failure: mode, placeholder: mode)
        }
        let loadingOptions = ImageLoadingOptions(placeholder: placeHolder, failureImage: failureImage, contentModes: contentModes)
        
        Nuke.loadImage(with: request, options: loadingOptions, into: base)
    }
}

ロード中と失敗時の画像として同一の UIImage を用いるケースが多いため、画像指定の分岐を吸収する関数を用意しています。 また UIImageView の拡張にすることで、 UIImageView のサイズに応じたリサイズ処理を実施しています。 サーバー側で実装していた画像のリサイズ処理をアプリ側に移譲することで、アプリ側でディスクキャッシュを利用するなどの機能改善の余地を作ることができました。

URLを受け取るUIImageViewの作成

続いてアプリ内でよく使う切り抜き処理などを加えた、汎用的なImageViewを作成します。 DefaultIconName を用意すると、利用時にenumを選択することで named を引数としたUIImageが作成されるようになります。

enum DefaultIconName: String {
    case user = "default_icon_user"
    case university = "record_icon_university"
}

extension DefaultIconName {
    func createIcon() -> UIImage? {
        return UIImage(named: self.rawValue)
    }
}

final class UrlImageView: UIImageView {
    
    enum ShapeType {
        case square
        case round(radius: CGFloat)
        case circle
    }
    
    enum EdgeType {
        case none
        case edge
    }
    
    private var imageUrl: String?
    private var crop: Bool
    private var shapeType: ShapeType
    private var defaultImage: UIImage?
    private var edgeType: EdgeType
    private var aspectMode: AspectMode
    private var loadImageContentMode: ContentMode?

    convenience init(size: CGSize,
                     imageUrl: String? = nil,
                     crop: Bool = false,
                     shapeType: ShapeType = .square,
                     defaultIconName: DefaultIconName,
                     edgeType: EdgeType = .none,
                     aspectMode: AspectMode = .aspectFill,
                     loadImageContentMode: UIView.ContentMode? = nil) {
        self.init(size: size,
                  imageUrl: imageUrl,
                  crop: crop,
                  shapeType: shapeType,
                  defaultImage: defaultIconName.createIcon(),
                  edgeType: edgeType,
                  aspectMode: aspectMode,
                  loadImageContentMode: loadImageContentMode)
    }
    

    init(size: CGSize,
         imageUrl: String? = nil,
         crop: Bool = false,
         shapeType: ShapeType = .square,
         defaultImage: UIImage? = nil,
         edgeType: EdgeType = .none,
         aspectMode: AspectMode = .aspectFill,
         loadImageContentMode: UIView.ContentMode? = nil) {
        self.imageUrl = imageUrl
        self.crop = crop
        self.shapeType = shapeType
        self.defaultImage = defaultImage
        self.edgeType = edgeType
        self.aspectMode = aspectMode
        self.loadImageContentMode = loadImageContentMode
        
        super.init(frame: CGRect(origin: .zero, size: size))
        
        self.image = defaultImage
        
        contentMode = .scaleAspectFit
        clipsToBounds = true
        isUserInteractionEnabled = true
        
        drawImage()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    /// Reset image view for [UITableViewCell.prepareForReuse](https://developer.apple.com/documentation/uikit/uitableviewcell/1623223-prepareforreuse)
    func reset() {
        image = defaultImage
        imageUrl = nil
    }
    
    /// Set the image URL and load the image from the network
    ///
    /// - Parameters:
    ///   - imageUrl: a url for loading an image
    func setImageUrl(imageUrl: String?) {
        self.imageUrl = imageUrl
        drawImage()
    }
    
    private func drawImage() {
        switch shapeType {
        case .square:
            switch edgeType {
            case .none:
                drawSquare()
            case .edge:
                drawEdgedSquare()
            }
        case .round(let radius):
            switch edgeType {
            case .none:
                drawRound(radius: radius)
            case .edge:
                drawEdgedRound(radius: radius)
            }
        case .circle:
            switch edgeType {
            case .none:
                drawCircle()
            case .edge:
                drawEdgeCircle()
            }
        }
    }
    
    private func drawSquare() {
        loadImage()
    }
    
    private func drawEdgedSquare() {
        drawEdge()
        loadImage()
    }
    
    private func drawRound(radius: CGFloat) {
        self.layer.cornerRadius = radius
        
        loadImage()
    }
    
    private func drawEdgedRound(radius: CGFloat) {
        self.layer.cornerRadius = radius
        
        drawEdge()
        loadImage()
    }
    
    private func drawCircle() {
        layer.cornerRadius = width * 0.5
        
        loadImage()
    }
    
    private func drawEdgeCircle() {
        layer.cornerRadius = width * 0.5
        
        drawEdge()
        loadImage()
    }
    
    private func loadImage() {
        (self as UIImageView).ex.loadUrl(imageUrl: imageUrl, processorOption: .resize, aspectMode: aspectMode, crop: crop, defaultImage: image, contentMode: loadImageContentMode)
    }
    
    private func drawEdge() {
        layer.borderColor = UIColor.defaultTint().cgColor
        layer.borderWidth = 0.5
    }
}

デフォルト画像の角丸や円形の切り抜きに対応するため UrlImageView の角に処理を加えています。 こういったImageViewの操作がiOSは対応しやすいので、大変感動しました。

コード追加による効果

UrlImageView を導入したことで、旧来の画像読み込み処理を素早く置き換えることができました。 SwiftのコードをObjective-Cから呼び出すことで、手を出しづらかった歴史的なコードの改修ができるようになったことは、個人的なモチベーションの向上にもつながっています。

その他には、一部の画像において旧来の処理よりも解像度がよくなったように見える箇所が存在しています。 旧来のコードのリファクタリングは少々困難な状態にあったため、良い形でリプレースすることができました。

終わりに

今回が初めて業務でのiOS開発でした。 SwiftやXcodeをはじめ戸惑うことは多かったのですが、Appleのドキュメントが非常に充実しており大変開発に取り組みやすかったです。

今後もアレコレと対応を進め、iOSアプリの体験を向上できればと考えています!