iOSでバックグラウンドファイルアップロード

f:id:optim-tech:20200327194638p:plain

こんにちは、R&Dチームの中村です。最近 iOSアプリでファイルをバックグラウンドでアップロードしたい という場面に出くわしたので、その時に調べたこと・実際に手を動かして確認するために作った簡素なアプリをまとめます。意外とバックグラウンドダウンロードに関してはそこそこあるものの、アップロードに関してまとまったものが少なかったというのも動機の1つです。確認している手元の環境は以下のようになります。

  • MacBook Pro 2012 Mid
    • macOS Mojave 10.14.6(18G3020)
    • Xcode Version 11.3 (11C29)
      • Swift 5.1
    • iOS Simulator Version 11.3
      • iPhone 8 (iOS 13.3)

2019年のiOSバックグラウンド事情

本題からは少しそれますが、iOS・Androidのようなモバイル端末上でバックグラウンド動作するアプリには、ネットワーク帯域・バッテリーなどのリソースを効率的につかうための制約がつきものになります。そのためアプリがフォアグラウンドでない状態になっても処理を継続させておきたかったり、定期的に処理をさせたい場合はOSの規則や挙動に沿った記述が必要になります。

iOS13.0以前のバックグラウンドでの定期的な処理はBackground fetchを使うことが一般的でした。これを置き換えるような形でiOS13.0からBackgroundTasks Frameworkが新しく導入されました。従来の定期的なバックグラウンド処理に加えて、遅延可能で時間のかかる処理(データのバックアップやCoreMLでの学習作業等)を実行するためのBGProcessingTaskが用意されました。CoreMLでの学習作業も視野に入っているあたり、リソースをそれなりに使うようなケースでも問題なさそうです。従来と同じような定期的なバックグラウンド処理に関しては、BGAppRefreshTaskが用意されました。30秒以内で完了するようなアプリの状態を最新に保つため処理が想定されています。ただし 電源に接続されている状態が必須ネットワークに接続されている状態が必須 およびその他の制約により実行タイミングはOS側が決めるので、きっちりとした頻度では実行されないという点もあります。BackgroundTasks Frameworkを使うには、Xcode上で以下のCapabilitiesの設定を行います。

  1. プロジェクトファイルを選択
  2. Signing & Capabilitiesタブを選択
  3. ~ 4. + Capabilityボタンを押してBackground Mode を選択
  4. Background App Refresh Tasks (BGAppRefreshTask)を利用する場合には、Background fetchへチェックを入れる
  5. Background Processing Tasks (BGAppRefreshTask)を利用する場合には、Background processingへチェックを入れる
  6. info.plistを編集してPermitted background task scheduler identifiersにタスクのidentifierを設定する

identifierは一位である必要があるため、逆ドメイン(com.example.app.taskのような形式)が推奨されます。

f:id:optim-tech:20200330122243p:plain

f:id:optim-tech:20200330122239p:plain

f:id:optim-tech:20200330123933p:plain

アプリのバックグラウンドの挙動周りはモバイルデバイスのハードウェア・OSの進化で変わることが多いので、継続的にウォッチしておきたいですね。

※ 今回のテーマのバックグラウンドアップロード(Background Transfar)に関しては特にこういったCapability周りの設定は必要ありません。

目標

今回は以下の目標の達成を条件に進めていきたいと思います。

  • アプリ
    1. iPhoneのフォトギャラリーから画像を選んでアップロードすることができる
    2. アップロードする画像は1つとする
    3. アップロードを始めた後にアプリをバックグランドにしても継続される
    4. アップロードが完了するとローカル通知を出す
  • サーバー
    1. [POST] /photoのエンドポイントでアップロードできる
    2. multipart/form-dataリクエストでContent-Disposition: form-data; name=photoのファイルを受け取るとカレントディレクトリへ保存する
    3. 正常系のレスポンスとして{"filename": "アップロード時に指定したファイル名"}のjsonを返す

バックグランドアップロードの下調べ

バックグランドでのファイルダウンロードはURL Loading SystemDownloading Files in the Background項に記載されている通りです。ファイルのアップロードの具体的な方法に関してはこのページ内には記載がありませんでしたが、Only upload tasks from a file are supported (uploads from data instances or a stream fail after the app exits). という記載があるため基本的にバックグラウンド状態でのアップロードは ファイルから に限られるようです。バックグラウンドでのファイルダウンロードと、Uploading Data to a Websiteを踏まえると以下の手順を踏む必要がありそうです。

  1. UploadTaskの状態の変更を受け取るためのDelegateを用意する
  2. バックグランドタスク用のURLSessionConfigurationを生成する
  3. 2.のURLSessionConfigurationを引数に入れてURLSessionを生成する
  4. アップロード対象のファイルをmultipart/form-dataに沿うような形でエンコードする
  5. 4.でエンコードしたデータをファイルへ出力しURLRequestを組み立てる
  6. URLSessionのメソッドuploadTask(with:fromFile:)でアップロードタスクを開始する
  7. レスポンスを受け取る

やってみる

iOSアプリのコードが断片的になっていますが、最後にまとめたものを準備しています。

アップロード先サーバーの準備

ファイルのアップロード先のサーバーはPython + FastAPIで準備しました。以下のコードで目標のサーバーの項を満たすサーバーとして動作します。

fastapi.tiangolo.com

# server.py
from fastapi import FastAPI, File, UploadFile


app = FastAPI()


@app.post('/photo')
async def uploadPhoto(photo: UploadFile = File(...)):
    filename = str(photo.filename)
    photo_data = await photo.read()

    with open(f"./{filename}", mode="wb") as f:
        f.write(photo_data)

    return {"filename": filename}

あとはuvicorn --host 0.0.0.0 --port 3000 server:appでサーバーが立ち上がります。serverの部分はファイル名の拡張子を取り除いたファイル名になります。細かいインストール方法や挙動・設定はFastAPIのチュートリアルを参考にしてください。

ImagePickerでフォトライブラリから画像を選択する

UIImagePickerControllerDelegateを継承させたViewControllerで以下の2つのメソッドを実装することでフォトライブラリから画像を選択できるようになります。このときのimageURLは選択した画像がテンポラリディレクトリへコピーされた後のパスが入っているのでそのまま使います。

// ViewController.swift

func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        
    if let imageURL = info[.imageURL] as? URL {
        // imageURL = tmpフォルダへコピーされた画像のパス
    }

    picker.dismiss(animated: true, completion: nil)
}
// ViewController.swift

func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
    picker.dismiss(animated: true, completion: nil)
}

1. UploadTaskの状態の変更を受け取るためのDelegateを用意する

バックグランドでのアップロード・ダウンロードを行う際の失敗・成功やサーバーからのレスポンスはDelegateを通して処理しなければいけないのでそれらを準備します。今回ViewControllerではURLSessionTaskDelegateURLSessionDataDelegateを継承させ、メソッド3つ実装しています。AppDelegateではUIApplicationDelegateを継承させてメソッドを1つ実装しています。

urlSessionDidFinishEvents(forBackgroundURLSession:)

ドキュメントにも記載がありますがアプリがバックグランドかつ停止状態でデータ転送が完了した際に、iOSが自動的にアプリを再起動させapplication(_:handleEventsForBackgroundURLSession:completionHandler:)を呼び出します。completionHandlerが呼び出されるとアプリは再度停止状態になってしまう(=以降Delegateが呼ばれなくなる)ため、urlSessionDidFinishEvents(forBackgroundURLSession:)ですべてのバックグラウンドタスクが終了したタイミングでcompletionHandlerを呼び出すようにします。

// AppDelegate.swift

var completionHandler: (() -> Void)?

func application(_ application: UIApplication,
                    handleEventsForBackgroundURLSession identifier: String,
                    completionHandler: @escaping () -> Void) {
    completionHandler = completionHandler
}
// ViewController.swift

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    DispatchQueue.main.async {
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
            return
        }

        if let completionHandler = appDelegate.completionHandler {
            completionHandler()
        }
    }
}

urlSession(_:dataTask:didReceive:)

サーバーからデータを受け取った時 に呼ばれるDelegateです。今回の場合ではクライアントがサーバーからデータを受け取るタイミングは アップロード後のレスポンス のみになるため、1リクエスト中に1回呼ばれることになります。またこのDelegateはあくまで サーバーからデータを受け取った時に呼ばれる ものなので、リクエストの成功・失敗に関わらず呼び出されます。複数回に分けてデータを受け取るようなタスクでは複数回呼び出されます。

// ViewController.swift

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
    guard let response = dataTask.response as? HTTPURLResponse else {
        fatalError("No response")
    }
    // StatusCodeの正常・異常に関わらずレスポンスがあれば呼び出される
    // ResponseBodyは`data`に格納されています
}

urlSession(_:task:didCompleteWithError:)

データの転送が完了した際に呼ばれるDelegateです。didCompleteWithErrorにあるように、正常・異常に関わらず呼び出されるため、このDelegateが呼ばれたから正常に終わっているとは限りません。ドキュメントにある通りDNS周りのエラーや、そもそもホストに接続できない等のクライアント側のエラーでサーバーへデータ送れなかった場合に、ここで判断することができます。

// ViewController.swift

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
    if let _error = error  {
        fatalError(_error)
    }
}

2. ~ 3. URLSessionを生成するまで

ドキュメントのURLSessionConfigurationを参考に生成します。必要に応じてタイムアウトまでの時間や、クッキーなどの設定ができます。

let sessionConfig = URLSessionConfiguration.background(withIdentifier: "upload-sample")
sessionConfig.isDiscretionary = false  // OSのタイミングにまかせてアップロードさせる場合はコメントアウト

session = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)

4. アップロード対象のファイルをmultipart/form-dataに沿うような形でエンコードする

URLSessionにはuploadTask(with:fromFile:)のようにファイルをアップロードするメソッドが用意されています。一見このメソッドを使ってアップロードすればいいように思いますが、multipart/form-dataコンテンツタイプの場合はデータの境界を示すためのboundaryを付与したりする必要があるので、データの整形を行う必要があります。(自動的に合うようにエンコードしたりはしてくれません) multipart/form-dataコンテンツタイプに関する情報はMDN - POSTや、MDN - Content-Typeに書かれています。

以下のmultipart/form-data用のデータを生成するメソッドを作成しました。

func createMultipartFormdataBody(_ files: [(path: URL, name: String, dispositionName: String)], boundary: String) -> Data {
    
    let contentDisposition = {(url: URL, name: String) -> Data in
        return "Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(url.lastPathComponent)\"\r\n".data(using: .utf8)!
    }
    
    let contentType = {(path: URL) -> Data in
        switch path.pathExtension.lowercased() {
        case "jpeg", "jpg":
            return "Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!
        case "png":
            return "Content-Type: image/png\r\n\r\n".data(using: .utf8)!
        case "gif":
            return "Content-Type: image/gif\r\n\r\n".data(using: .utf8)!
        case "bmp":
            return "Content-Type: image/bmp\r\n\r\n".data(using: .utf8)!
        default:
            fatalError("Unknown extension")
        }
    }
    
    var data = Data()

    for (path, dispositionName) in files {
        data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
        data.append(contentDisposition(path, dispositionName))
        data.append(contentType(path))
        
        let content = try! Data(contentsOf: path)
        data.append(content)
    }
    
    data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
    
    return data
}

余談ですがAlamofireを使うとこのあたりをやらなくて済みます。

github.com

5. ~ 6. URLRequestを組み立ててuploadTask(with:fromFile:)でアップロードタスクを開始する

createMultipartFormdataBody(files:boundary:)を用いて生成されたデータを一度ファイルへ書き込んで、そのファイルをuploadTask(with:fromFile:)でアップロードさせます。前述した通りBackground TransfarではuploadTask(with:from:)で直接データをアップロードすることができないため一時的にファイルへ書き込みます。

func upload(_ filePath: URL){
        
    let boundary = UUID().uuidString
    
    var req = URLRequest(url: self.serverURL, timeoutInterval: 3000)
    req.httpMethod = "POST"
    req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

    let uploadName = filePath.lastPathComponent
        
    let data = self.createMultipartFormdataBody([(filePath, uploadName, "photo")], boundary: boundary)
    
    let uploadData = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
        .appendingPathComponent(boundary)
            
    try! data.write(to: uploadData)
    
    let task = session.uploadTask(with: req, fromFile: uploadData)
    task.resume()
}

7. レスポンスを受け取る

1.で実装した中でurlSession(_:dataTask:didReceive:)のDelegateでレスポンスを受け取ります。一応フォアグラウンド状態でも通知を受け取るようにしておきたいので、AppDelegateにも変更を入れます。

// ViewController.swift

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
    guard let _response = dataTask.response as? HTTPURLResponse else {
        fatalError("No response")
    }
    
    var responseBody: String?
        
    switch _response.statusCode {
    case 200:
        responseBody = String(data: data, encoding: .utf8)
            
    default:
        break
    }

    // ローカル通知を出す
    let content = UNMutableNotificationContent()
    content.title = "アップロード完了"
    content.body = "Response statusCode: \(_response.statusCode)"
    content.sound = .default

    let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
    let request = UNNotificationRequest(identifier: "upload-sample",
                                        content: content,
                                        trigger: trigger)

    let center = UNUserNotificationCenter.current()
    center.add(request) { (error) in
        if let error = error {
            print(error.localizedDescription)
        }
    }

}
// AppDelegate.swift
// 通知の許可ダイアログ
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    let center = UNUserNotificationCenter.current()
    center.delegate = self
    
    center.requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in
        if !granted {
            print("Didn't allowed notification")
        }
    }
    
    return true
}

// フォアグラウンドで通知を受け取るためのメソッド(フォアグラウンドで試す時以外はコメントアウト)
func userNotificationCenter(_ center: UNUserNotificationCenter,
                            willPresent notification: UNNotification,
                            withCompletionHandler completionHandler: (UNNotificationPresentationOptions) -> Void) {
    
    completionHandler([.alert, .badge, .sound])
}

UIを足してアプリにまとめる

Single View Appでプロジェクトを作成し、AppDelegateとViewControllerの編集に加えて、StoryBoardでButtonを2つ、LabelとTextFieldを1ずつを作成しています。

AppDelegate.swift(クリックして展開します)

// AppDelegate.swift

import UIKit
import UserNotifications

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    var backgroundCompletionHandler: (() -> Void)?
    
    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        backgroundCompletionHandler = completionHandler
    }

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let center = UNUserNotificationCenter.current()
        center.delegate = self
        
        center.requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in
            if !granted {
                print("Didn't allowed notification")
            }
        }
        
        return true
    }

    // MARK: UISceneSession Lifecycle

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
        // Called when the user discards a scene session.
        // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
        // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
    }

    /*
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler: (UNNotificationPresentationOptions) -> Void) {
        
        completionHandler([.alert, .badge, .sound])
    }
    */
}

ViewController.swift(クリックして展開します)

// ViewController.swift

import Foundation
import UIKit
import Photos
import UserNotifications


class ViewController: UIViewController, URLSessionTaskDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate, URLSessionDataDelegate {

    @IBOutlet var selectButton :UIButton!
    @IBOutlet var uploadButton :UIButton!
    @IBOutlet var uploadImageName :UITextField!
    @IBOutlet var message :UILabel!

    let serverURL = URL(string: "http://[YOUR HOST]/photo")!
    var session :URLSession!
    var uploadImageSource: URL?
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)

        let sessionConfig = URLSessionConfiguration.background(withIdentifier: "upload-sample")
        sessionConfig.isDiscretionary = false  // OSのタイミングにまかせてアップロードさせる場合はコメントアウト
        self.session = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        
        if let _error = error as NSError? {
            
            DispatchQueue.main.sync {
                self.message.textColor = .red
                self.message.text = """
                                    \(_error.localizedDescription)
                                    task: \(task.taskIdentifier) code: \(_error.code)
                                    """
            }
            
            return
        }
        
    }
    
    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {

        DispatchQueue.main.async {
            guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
                let backgroundCompletionHandler = appDelegate.backgroundCompletionHandler else {
                    return
            }
            
            backgroundCompletionHandler()
        }
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        guard let _response = dataTask.response as? HTTPURLResponse else {
            fatalError("No response")
        }
        
        DispatchQueue.main.sync {
            var responseBody: String?
            
            switch _response.statusCode {
                
            case 200:
                responseBody = String(data: data, encoding: .utf8)
                self.message.textColor = .green
                
            default:
                self.message.textColor = .red
            }
            
            self.message.text = """
                Response \(_response.statusCode)
                taskID: \(dataTask.taskIdentifier)
                response: \(responseBody ?? "nil")
                """

            let content = UNMutableNotificationContent()
            content.title = "アップロード完了"
            content.body = "Response statusCode: \(_response.statusCode)"
            content.sound = .default

            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
            let request = UNNotificationRequest(identifier: "upload-sample",
                                                content: content,
                                                trigger: trigger)

            let center = UNUserNotificationCenter.current()
            center.add(request) { (error) in
                if let error = error {
                    print(error.localizedDescription)
                }
            }
        }
    }
    
    @IBAction func selectFile(_ sender : Any) {
        let picker = UIImagePickerController()
        picker.delegate = self
        picker.sourceType = .photoLibrary
        
        self.present(picker, animated: true)
    }
    
    @IBAction func uploadFile(_ sender : Any) {
        
        guard let souce = self.uploadImageSource else {
            return
        }
        
        self.upload(souce)

        self.message.textColor = .black
        self.message.text = "Uploading"
    }

    func upload(_ filePath: URL){
        
        let boundary = UUID().uuidString
        
        var req = URLRequest(url: self.serverURL, timeoutInterval: 3000)
        req.httpMethod = "POST"
        req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

        let uploadName = "\(uploadImageName.text ?? UUID().uuidString).\(filePath.pathExtension)"
         
        let data = self.createMultipartFormdataBody([(filePath, uploadName, "photo")], boundary: boundary)
        
        let uploadData = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
            .appendingPathComponent(boundary)
               
        try! data.write(to: uploadData)
        
        let task = session.uploadTask(with: req, fromFile: uploadData)
        task.resume()
    }
    
    func createMultipartFormdataBody(_ files: [(path: URL, name: String, dispositionName: String)], boundary: String) -> Data {
        
        let contentDisposition = {(name: String, dispositionName: String) -> Data in
            return "Content-Disposition: form-data; name=\"\(dispositionName)\"; filename=\"\(name)\"\r\n".data(using: .utf8)!
        }
        
        let contentType = {(path: URL) -> Data in
            switch path.pathExtension.lowercased() {
            case "jpeg", "jpg":
                return "Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!
            case "png":
                return "Content-Type: image/png\r\n\r\n".data(using: .utf8)!
            case "gif":
                return "Content-Type: image/gif\r\n\r\n".data(using: .utf8)!
            case "bmp":
                return "Content-Type: image/bmp\r\n\r\n".data(using: .utf8)!
            default:
                fatalError("Unknown extention")
            }
        }
        
        var data = Data()

        for (path, name, dispositionName) in files {
            data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
            data.append(contentDisposition(name, dispositionName))
            data.append(contentType(path))
            
            let content = try! Data(contentsOf: path)
            data.append(content)
        }
        
        data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
        
        return data
    }
    
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
            
        if let imageURL = info[.imageURL] as? URL {
            self.uploadImageSource = imageURL
            self.uploadImageName.text = imageURL.deletingPathExtension().lastPathComponent
        }
        picker.dismiss(animated: true, completion: nil)
    }

    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        picker.dismiss(animated: true, completion: nil)
    }

}

StoryBoard(スクリーンショット)

f:id:optim-tech:20200327163959p:plain

  • アップロード名編集用UITextField
  • 画像選択用UIButton
  • アップロード用UIButton
  • レスポンスメッセージ表示用UILabel

フォアグラウンドで通知が出る状態で動かしてみると、このような動作になります。(正常系と404の場合)

f:id:optim-tech:20200327165411g:plain:h500

試してみる

実際にバックグラウンドアップロードを試してみます。この画像*1をアップロードします。

f:id:optim-tech:20200327184803j:plain:h500

結果以下のような操作を行った結果、無事にアップロードされました。

  1. アップロードを始める
  2. ホームボタンを押してアプリをバックグラウンド状態にする
  3. 電源ボタンを押してシュミレータのiPhoneをスリープ状態にする
  4. じっと待つ

f:id:optim-tech:20200327193158g:plain:h500

実際にQuickTimeで画面録画を用いて計測したアップロード所要時間は約2分59秒でした。

サーバーにも無事アップロードされていました。

f:id:optim-tech:20200327193225p:plain

f:id:optim-tech:20200327193222j:plain:h500

ちなみにURLSessionにはアップロードの進捗が取れるDelegateや、バックグラウンドアップロードに失敗したタスクを取れるDelegate等ほかにもよりアプリを便利にしてくれるものがあるのでぜひリファレンスを見てみてください。

所感

オプティムは事業領域が広いため実装環境もサービスごとに多種多様です。企業全体の体験としても、個人の体験としても新しいことをやってみることが多い中でドキュメントを読んでもピンと来ないことが少なくとも私にはちょくちょくあります。 そんなときはやっぱり初心に返って試しに手を動かして実装してみることが一番の近道だなと今回 思わされました。

そんな自分の未知の領域を楽しく一緒に開拓していくメンバーを絶賛募集しております!こちらをご一読いただければ幸いです。

www.optim.co.jp

謝辞

アイキャッチ画像素材: いらすとや 様

*1:私が昨年ソロ旅行したときのものです