Studyplus Engineering Blog

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

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アプリの体験を向上できればと考えています!

Firebase App DistributionへFabric Betaから社内向けアプリ配信を移行しました

こんにちは、新生モバイルクライアントグループの若宮(id:D_R_1009)です。 今回は社内向けのテストアプリ配信の更新について書きたいと思います。

移行の経緯

スタディプラスAndroidチームでは歴史的な経緯により、Fabric Betaを長らく利用していました。 しかしながら、2020年3月をもってFabric Betaがクローズすることになったため別のサービスへ移行する必要が生じました。

get.fabric.io

いくつかのサービスを検討し、結局Firebase App Distributionを利用することとしました。 いつの間にかベータ版になっていたのが決め手です。

firebase.google.com

試行錯誤したところ、今までと変わらない(一部より利便性を増して)環境構築をできたので共有したいと思います。 「もっといい方法があるよ!」などあれば、ブックマークにコメントを付けて頂けると嬉しいです!

テストアプリ配信環境

テストアプリを配信するにあたり、次の2つの方法を用意しています

  1. CI上で次期リリース用PR作成時に検証環境( Stage )/開発環境( Debug )向きのapkを作成し配信する
  2. ローカル環境(開発者のマシン)から検証環境( Stage )/開発環境( Debug )向きのapkを作成し配信する

1つ目はよくあるCD環境ではないかなと思います。 リリース直前に一通りアプリを触ってテストするため、リリース用PRをベースにビルドを走らせています。 また、サーバーチームのエンジニアが実際にアプリからサーバーAPIを叩く処理を触ることがあるため、社内向けにストアから配信したコードとほぼ同じものを配信する目的もあります。

2つ目のローカルマシンによる実行を用意しているのは、開発者が少なく機能ごとの開発が多いため、検証環境向きのデイリービルドを行っていないためです。 動いているプロジェクトの状況に応じて、またサーバーチームやデザイナーの求めに応じてビルドを走らせてるため、ローカル環境からアップロードできる仕組みを用意しています。

今回Firebase App Distributionを利用するにあたり、このCIとローカルの2つの環境でビルドを走らせるのが少々難しくなっていました。

fastlaneによるlaneの用意

CIとローカル環境用のlaneを用意しておきます。 大きな違いとしてはCI用のlaneでは firebase cli tokenfirebase cli path を設定しておきますが、ローカル環境用では用意していません。

default_platform(:android)

  desc "Debug build and upload Firebase Distribution(for CI)"
  lane :build_and_deploy_debug do
    gradle(
      task: "assemble",
      build_type: "Debug"
    )
    firebase_app_distribution(
      app: ENV["APP_ID_DEBUG"],
      testers_file: "./tester/testers.txt",
      groups_file: "./tester/groups.txt",
      release_notes_file: "./app/BetaDistributionReleaseNotes.txt",
      firebase_cli_token: ENV["FIREBASE_CLI_TOKEN"],
      firebase_cli_path: "./node_modules/.bin/firebase"
    )
  end

  desc "Debug build and upload Firebase Distribution(for local machine)"
  lane :build_and_deploy_debug_local do
    gradle(task: 'clean')
    gradle(
      task: "assemble",
      build_type: "Debug"
    )
    firebase_app_distribution(
      app: ENV["APP_ID_DEBUG"],
      testers_file: "./tester/testers.txt",
      groups_file: "./tester/groups.txt",
      release_notes_file: "./app/BetaDistributionReleaseNotes.txt"
    )
  end
end

CI用の FIREBASE_CLI_TOKEN は公式ドキュメントの通り firebase login:ci を利用して取得しています。

Firebase CLI reference  |  Firebase

Firebase App Distributionの利用はいくつか選択肢がありますが、個人的には fastlane の利用が一番やりやすいのではないかと思います。

firebase.google.com

CircleCIではAndroid用のDocker ImageにRubyの環境が用意されているため、すぐに利用することができます。 もしも fastlane の導入が気になるようでしたら、下記記事を是非ご覧ください。

tech.studyplus.co.jp

CI(CircleCI)の設定

CircleCIではグローバルな環境にFirebase CLIをインストールできないため、作業ディレクトリにnpmを利用してFirebase CLIを用意します。 標準のAndroid用Docker Imageではnodeが用意されていないため -node 付きのnodeが利用できるImageを利用するのが良いと思われます。

version: 2.1
executors:
  android_defaults:
    docker:
      - image: circleci/android:api-29-node

commands:
  setup_bundle:
    steps:
      - restore_cache:
          keys:
            - v1-bundle-cache-{{ checksum "Gemfile.lock" }}
            - v1-bundle-cache-
      - run:
          name: Set path and clean option
          command: |
            bundle config set path './vendor/bundle'
            bundle config set clean 'true'
      - run:
          name: bundle install
          command: bundle check || bundle install --jobs=4 --retry=3
      - save_cache:
          paths:
            - ./vendor/bundle
          key: v1-bundle-cache-{{ checksum "Gemfile.lock" }}

  setup_node:
    steps:
      - restore_cache:
          keys:
            - v1-npm-cache-{{ checksum "package-lock.json" }}
            - v1-npm-cache-
      - run:
          name: npm install
          command: npm install
      - save_cache:
          paths:
            - ./node_modules
          key: v1-npm-cache-{{ checksum "package-lock.json" }}

  setup_android:
    steps:
      - restore_cache:
          keys:
            - v1-gradle-cache-{{ checksum "build.gradle" }}-{{ checksum  "app/build.gradle" }}
            - v1-gradle-cache-
      - run:
          name: Download Dependencies
          command: ./gradlew androidDependencies
      - save_cache:
          paths:
            - /home/circleci/.gradle
          key: v1-gradle-cache-{{ checksum "build.gradle" }}-{{ checksum  "app/build.gradle" }}
  run_build_debug:
    steps:
      - run:
          name: Create new debug build(.apk) and upload Firebase Distribution
          command: bundle exec fastlane android build_and_deploy_debug

jobs:
  build_debug:
    executor:
      name: android_defaults
    steps:
      - checkout
      - setup_bundle
      - setup_node
      - setup_android
      - run_build_debug

workflows:
  deploy:
    jobs:
      - build_debug:
          filters:
            branches:
              only:
                - /^release.*/

Firebase App Distributionへapkを送信する処理がfastlaneに閉じているため、CircleCIではキャッシュの管理程度のステップになります。 なお実運用しているステップでは、途中でリリースノートにブランチ名やコミットログの書き込みなどを行っています。

開発者マシン(shell script)の設定

Mac OSとLinux向けに、Firebase CLIがインストールコマンドを叩いている環境に応じて環境構築をしてくれるスクリプトを公開しています。

Firebase CLI reference  |  Firebase

スタディプラスのモバイルクライアントチームは全員がMacBookProを利用しているため、こちらのスクリプトを採用しました。 このことにより firebase login コマンドの対話的な処理で firebase cli token に該当するリフレッシュトークンを取得することができるようになります。

#!/bin/zsh
echo "Build debug apk, and upload Firebase Distribution"

echo "Login firebase"

curl -sL https://firebase.tools | bash
firebase login

echo "Update local module"

# bundle
bundle config set path './vendor/bundle'
bundle config set clean 'true'
bundle install

echo "Start build"

# Build and upload
bundle exec fastlane android build_and_deploy_debug_local

echo "Done!"

事前にRubyやrbenvによる環境構築が必要となりますが(Bundlerのバージョンアップのため)、一度設定するだけだったので共有コストもほとんどかかりませんでした。 実行時間も短い(6分程度)のため、気軽に実行できる環境が維持されています。

終わりに

今回はFabric BetaからFirebase App Distributionへの移行事例を紹介しました。 実作業時間としては3〜4時間程度だっため、作業負荷もそこまで高くなく、改めてFirebaseの使いやすさを確認することとなりました。

現状ではアプリサイズが原因なのか、Debugのビルド時間にばらつきがあるため、引き続きCI設定を改善していきたいと思います!

開発プロセスを振り返ってみた話

こんにちは、サーバーサイドチームの山下です。 今回は昨年末にチームで実施した開発プロセスの振り返り会についてお話します。

背景

これまでの開発

私が所属しているサーバーサイドチームは、学習記録システムや特集記事配信システムなど、複数のシステムを扱っています。新規機能開発や改修があるとプロジェクトが発足し、各メンバーがアサインされ他チームと連携しながら開発を進める形式がメインです。

そのため、私が入社した時のチームは各々作業を進める雰囲気が強く、どうしても特定のシステムや機能開発が属人化してしまったり、他のメンバーが困っていることに気付けないということがありました。

そんな中、CSM(認定スクラムマスター)の資格を取得したエンジニアが中心となり2019年の夏頃からスクラムでの開発にチャレンジしていました。

なぜ振り返りをしたのか

2019年末のタイミングでこれまでスクラムイベントなど主導してくれていたスクラムマスターがチームを離れることになり、またその他のメンバーも数名入れ替ることになりました。

もともとスクラム自体も「試しにやってみよう!」という形で始まったので、やってみてどうだったか・今後どうしていくかをチームとして認識を合わせる良い機会だと思い、振り返りの場を設けることにしました。

準備

せっかくやるなら今後の開発に活かせるよう、有意義な会にしたく以下の準備や環境作りをしました。

  1. 事前にアジェンダ・タイムボックスを決めシミュレーションしておく
  2. 振り返り中はPCを開かせないようにする

1については当たり前のことかもしれませんが、どうすれば時間内で目的を達成できるか事前にスクラムマスターと一緒に考え、振り返り方法やタイムボックスを決めました。 普段のレトロスペクティブでやっていたKPTではおそらく時間オーバーしたり、まとまらなかったりしていたと思うので、シミュレーションはやっておいて正解でした。

また、2についてはPCを開くとどうしてもslackや他のタスクが気になってしまうので、テーブルは除けて椅子とホワイトボードのみの場にしました。これも議論に集中できて良かったと思います。

振り返りの流れ

全員でまとまった時間をとるのが難しかったため、二部制にしました。

  • [第一部] スクラムやってみてどうだった会
  • [第二部] 今後どうしましょうか会

[第一部] スクラムやってみてどうだった会

この会の目的は「今後の開発プロセスをどうするか決めるために、これまでのプロセスを振り返る」としました。 スクラムイベント毎にGood(良かったこと・続けたいこと)とMotto(改善したいこと・不満)を付箋に書き、各々その理由を共有していきました。 f:id:syamashi:20200109113438j:plain

[第二部] 今後どうしましょうか会

第二部では、第一部の内容を踏まえ今後のプロセスをどうしていくか話し合います。 まずは第一部でGoodになっていることは今後も続けていくことをチームとして合意し、Mottoになっていることをどう改善していくかを決めました。 全てのMottoについて検討すると時間がかかりすぎるのでドット投票で絞って改善案をだしていきました。 以下のような流れです。

  1. Good/Mottoを思い出す
  2. ドット投票で解決したいMottoを決める
  3. 解決案を出す
  4. 解決案の中から試して見るものを決める

f:id:syamashi:20200109113705j:plain

やってみての感想

通常のレトロスペクティブではどうしても普段の作業についての振り返りがメインとなり、スクラムイベント自体を振り返る機会がなかなかありませんでした。

各メンバーが普段の開発プロセスに対してどんな期待や不満があるのか、どうしたら改善できるかを話し合えたのはとても良かったと思います。 また、事前にシミュレーションなどしていたため当日はスムーズに進めることができ、改めて準備や場作りは大切だと感じました。

まとめ

今回は私達のチームで行った開発プロセスの振り返りについてお話しました。

皆さんのチームでもメンバーの入れ替わりなどチーム状況が変わったり、ちょっとマンネリ化してきたなと感じたら、普段の作業プロセス自体を振り返ってみるのものよいかもしれません。

WorkManager + Dagger2によるバックグラウンド処理

こんにちは、Androidチームの若宮(id:D_R_1009)です。 昨年末にAndroidチームが導入した、WorkManagerをDagger2と組み合わせる方法を紹介します。

WorkManagerとは

developer.android.com

WorkManagerは、確実に実行したい非同期処理に対して利用するAndroid Architecture Componentsとなります。 概要についてはDroidKaigi 2019の「実践 WorkManager」をご一読ください。

speakerdeck.com

また、CodeLabも用意されています。

codelabs.developers.google.com

WorkManager + Dagger2(DI)

StudyplusのAndroidアプリではDagger2を活用しているため、WorkManagerとDagger2を組み合わせる必要があります。 検索してみると、次の記事で組み合わせ方が紹介されていました。

proandroiddev.com

もともとAssistedInjectを利用していたこともあり、おおよそこの方法で導入することはできそうでした。 ただ、できれはWorkManagerも provide メソッドによる管理を行いたくなります。

と言うことで、対応していきます。

provide 対応

WorkManagerはインスタンス化する処理が(特に指定しなければ)デフォルトのものが利用されるため、この処理を切り替える必要があります。 対応したのがWorkManager 2.1のため、ドキュメントに従って AndroidManifest.xml に記述を加えます。

developer.android.com

この対応により、 WorkManager.getInscance(context) する前に Configuration.Provider をセットすることができます。

@Provides
@Singleton
fun provideWorkManager(
    context: Context,
    factory: WorkManagerFactory
): WorkManager {
    WorkManager.initialize(context, Configuration.Builder().setWorkerFactory(factory).build())

    return WorkManager.getInstance(context)
}

あとは記事の通り、諸々のモジュールをセットすれば完了です。

@Module(includes = [PresenterModule::class, WorkerBindingModule::class])
object WorkManagerModule {

    @Provides
    @Singleton
    fun provideWorkManager(
        context: Context,
        factory: WorkManagerFactory
    ): WorkManager
}

@Module(includes = [AssistedInject_PresenterModule::class])
@AssistedModule
internal interface PresenterModule

@MapKey
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class WorkerKey(val value: KClass<out ListenableWorker>)

@Module
interface WorkerBindingModule {
    @Binds
    @IntoMap
    @WorkerKey(HogeWorker::class)
    fun bindHogeWorker(factory: HogeWorker.Factory): ChildWorkerFactory
}

利用状況、所感

StudyplusのAndroidアプリでは、WorkManagerをRepository層とViewModel層の間にある概念として利用しています。 このため、DIによりViewModel層からWorkManagerを呼び出し、WorkManagerのWokerにRepositoryをInjectして非同期処理を実行する構成となります。

アプリケーションの各種ライフサイクルに影響を受けずに非同期処理を実行することができ、開発のしやすさが高まったと感じています。 アーキテクチャとしてMVVMを採用しているチームにおいては、有用な選択肢となるのではないでしょうか。

終わりに

簡単ではありますがWorkManagerとDagger2を組み合わせる方法の紹介と、その効果をまとめてみました。 WorkManagerはKotlin Coroutinesを利用することができるため、Repositoryの各メソッドをsuspend関数にしておくだけで、簡単に呼び出すことができます。

2020年も引き続き、よろしくお願いいたします。

EOF 2019に参加しました(イベントレポート)

こんにちは、スタディプラス iOSチームの大石(id:k_oishi)です。 2019/10/31に開催されたEOF 2019に参加しました。

f:id:k_oishi:20191226142847j:plain:w300

EOFとはEngineering Organization Festivalの略で最近役職として注目されているEngineering Manager(略してEM)のためのカンファレンスです。 以前、EM的な役職だったこともあり、最近のEM界隈にも興味がありましたので参加してきました。

オープニングトークではEngineering Managerに興味を持つ参加者同士で自己紹介や現在持っている課題などを共有しましょうという時間がありました。 偶然、隣にいらっしゃった方がプログラミング学習の際に弊社Studyplusを使用しているとのことでうれしい出会いとなりました。

それでは、個人的に気になったセッションの紹介と感想です。

「質とスピード」

和田卓人さん

TDDでおなじみ和田卓人さんによるセッションです。
※詳しい内容はセッションのスライドをご覧ください

このセッションはタイトルのとおり「質とスピード」がテーマになっています。 今回の講演が初演ということで貴重な機会だったかと思います。

初めに、与えられた時間に対しやるべきことが多い場合に品質を犠牲してしまうケースが多いが、品質を犠牲にすればスピードが得られるのか?という問いから始まりました。 結果としては、短期的にスピードは得られますが、長期的には逆効果になるというということでした。

スピードを優先して品質を犠牲にした結果、内部品質といわれる部分が影響を受けます。 内部品質はテスト容易性、理解容易性、変更容易性で構成されています。(これらをまとめて保守性ともいう) これらを犠牲にするとプロジェクトにどのような影響を及ぼすかは、ある程度の経験者であれば容易に想像できると思います。

次にスピードを落とせば保守性は上がるのか?という問いがありました。 作業時間が少なくても品質の高いコードを書く人がいれば、作業時間が十分にあっても品質の低いコードを書く人もいるからです。

つまり、質とスピードはトレードオフではなく、品質をアップするためのコストをかける必要があるということです。 ここでは品質アップの2つの考え方としてコストアップ説とコストダウン説が図解で紹介され興味深いものでした。

結論として長期的に見れば質がスピードを生むのであって、そのスピードがさらなる質を生み、そのループのなかで外部品質を生み、サービスの競争力を生み、売り上げを生むという関係があったのです。 プロジェクトによっては品質を犠牲にしてリリースを優先する場合もあると思います。また、すでにそのような状態のプロジェクトに途中から参加する場合もあると思います。そのような状況をどう改善していくかが1つのポイントでは無いかと思いました。

感想

以前の会社では、通常の開発スプリントを4週実施したら、次の1週はテクニカルスプリントでエンジニア主導での既存コードのリファクタリングや新技術の調査などを行うことができました。 また、現在のStudyplusのiOSチームでは毎週金曜日をリファクタリングと緊急性の無いクラッシュ対応や不具合対応を行う日としてプロダクトの改善を行い、毎週リリースするサイクルを回しています。 このようなことを定期的に行ってはいますが、今後も品質について考えていきたいと思いました。

おまけ

講演内容に引用された書籍は以下のとおりです。

  • アジャイルサムライ
  • ワインバーグのシステム思考法
  • レガシーコードからの脱却
  • エンジニアリング組織論への招待
  • エクストリームプログラミング
  • LeanとDevOpsの科学
  • Experiences of Test Automation
  • A Philosophy of Software Design

すでにご存知のタイトルも多いと思いますが、チェックしてみてはいかがでしょうか。

「レガシーコードからの脱却」

吉羽 龍太郎さん

スライド

書籍「レガシーコードからの脱却」を執筆された吉羽さんによるセッションです。
※詳しい内容はセッションのスライドをご覧ください

この本はタイトルからするとレガシーコードを改善するような内容に受け取れますが、実際はレガシーコードを生み出さないようにする方法論がまとめられているとのことでした。ちなみにタイトルにレガシーとつくと本が売れるそうです。 レガシーコードの定義は様々ですが、このセッションでは修正、拡張、作業が難しいコードと定義され、保守に多額のお金がかかるコードという定義です。

ユーザーに使われるソフトウェアは変更が必要になります。 機能の追加や既存の機能の更新などが想像できると思います。 しかし、これらの更新を事前に予測することは不可能です。 そのため、変更しやすいしておくことが大事であり、その変更に対応できないのはレガシーコードであるということでした。

では、最初からレガシーコードを作らないようにするにはどうすれば良いのでしょうか?

まずは開発プロセスです。 ウォーターフォールはリスクが後半になればなるほど顕在化して取り返しがつかなくなるので、登場してきたのがアジャイルという手法、さらにXPやScrumといった手法が登場しました。 当然、アジャイルでも失敗するときは失敗します。ソフトウェアが生み出す成果に必要な要素は問題設定力、開発力、チーム力です。

次にレガシーコードを作らないための9つのプラクティスの一部が紹介されましたので簡単にまとめます。

  • 1 やり方より先に目的、理由、誰のためかを伝える
    プロダクトオーナーの領域である何をしたいか、なぜしたいか(What)と開発者の領域(How)であるやり方を分離して、双方が創造的に協調してコンテキストを共有、理解することが大事。

  • 2 小さなバッチで作る
    タイムボックスとスコープボックスという概念やケイデンス、リソース効率、プロセス効率などが登場します。 まとめると品質を一定に保ち、間に合わなければスコープを減らす。そして、ソフトウェアの評価として顧客にとっての価値が提供できているのかを小さいバッチでリリースしてフィードバックの回数を増やしてより価値を高めるということでした。

  • 5 Cleanコードを作る
    いわゆる一般的なCleanコードの定義ではありますが、開発の速後向上のために日々の積み重ねが必要で、それによりすばやく働く(= きれいに働く)が実現できるとのこと

  • 8 設計は最後に行う
    ソフトウェア開発は開発中に仕様が追加されたり、あとから分かることがあると思います。それらを随時反映するために、まずコードが動作し、テストがある状態から設計を良くするという考え方です。

感想

全体的に理解は出来るのですが、現実ではそこまでうまくできていない部分が多々あると感じました。1つ1つの考え方や振る舞い方を取り入れるだけでも、少しずつ改善できるのではないでしょうか。 より理解を深めるために「レガシーコードからの脱却」をしっかり読もうと思いました。

最後に

以上、印象に残った発表を紹介させていただきましたが、他にも素晴らしいセッションが多数ありました。 最後に和田卓人さんのツイートを紹介します。

会場の廊下にはスポンサーのブースが設置されていましたが、ある会社さんのブースで自作キーボードのスイッチを交換されている方がいました。弊社の自作キーボード部の部員としてついついキーボード話をしてしまいました。 これもそのような機会だったと思っております。

f:id:k_oishi:20191226142857j:plain:w300

この度このようなイベントに参加することで普段得られない知見を得られ、新しい出会いがありました。 運営スタッフの方々、登壇者の方々に感謝いたします。