はじめに
部活のプロジェクトで「技術好きの学生向けのSNSアプリを開発しよう!」ということになりチーム開発をすることになりました。
そこで、TechTrainというエンジニア志望のU30向けメンタリングサービスを利用し、インターンという形でリリースを目指して開発を続けてきました。
結果としてはリリースならず...!となってしまいましたが...
メンターさんの指導のもと、実務レベルでのアプリ開発を4~12月の8ヶ月間行なってきたので得られたものが非常に多かったです...!
今回、僕はAndroid担当兼PMというポジションでこのプロジェクトに関わってきたので、それぞれの立場から記事を書こうと思う次第...
本記事は前半のAndroid編です!
今回、Android開発をした際の道のりを順を追って書いていきます。
また、メンターさんから頂いたアドバイス等の中から、ぜひ発信したいと思った内容を一部お伝えしたいと考えてます。
なお、Qiita初投稿なので、お手柔らかに...
アーキテクチャの選定
まず、アプリケーションの全体の大まかな構造を決定するに当たって考えなくちゃいけないのが「アーキテクチャ」ですね!
アーキテクチャを採用することによって、それぞれのドメインが負う役割が明確になるだけでなく、複数人で開発する場合にはタスクを分割しやすくなるといったメリットがあるように感じます。
特に、「どんなアプリケーションを作るか」は、アプリの画面構成やデータの流れに大きな影響を与えます。
ここで一度、今回発案したアプリの主な機能を列挙してみます。
- 自分の書いたことを投稿できる
- ユーザーの投稿を閲覧できる
- 技術系イベントを確認できる
- ユーザー情報(得意な言語や興味のある技術など)を確認できる
では、どんな特徴があるのか?
- データの更新が頻繁なSNSアプリ
- 縦型で画面回転無し
- Viewの構成は割とシンプル
一方で、どんなアーキテクチャが主流なのか
- MVP
- MVVM
- Flux
- Clean Architecture
それぞれについては次の記事に詳しく書かれているので是非ご参考になさってください
Androidアーキテクチャことはじめ ― 選定する意味と、MVP、Clean Architecture、MVVM、Fluxの特徴を理解する
今回のアプリでは、画面回転を行わないため、Activityのライフサイクルを監視し続ける必要がなく、サーバーからGETして表示/入力内容をサーバーにPOSTするといった非常にシンプルなデータフローなので1,4のアーキテクチャの組み合わせで開発することにしました。
...ということでアーキテクチャはMVP+Clean Architectureに決定しました!
アーキテクチャの構成
さて、MVP+Clean Architectureの構成を考えるわけですが、まずは具体的なドメインの分割が必要となってきますね。
以下、今回の基本的なドメイン分割です。
- Data
- Network - API通信を行う
- Domain
- Model - サーバーから取得するデータ型を定義
- Repository - APIメソッドを呼び出し、データ取得/送信を行う
- UseCase - 画面単位で必要となるデータを各種Repositoryから取得/送信
- Presentation
- View(Activity, Fragment) - 入出力を受け付ける
- Presenter - 内部制御を担当し、画面への表示等を指示する
- Store - 状態情報などを保持する
以下、それぞれの層の実装について説明します。
Data層の実装
NetWork層の実装
Network層にはAPIメソッドを定義したAPIインターフェースを定義します。
ライブラリの構成はRetrofit2+Gson+OkHttp3といった感じです。
以下、データ取得の例です。
以前に取得した最後のIDをもとに続きのデータを取得可能です。
interface ExampleApi {
@GET("examples")
suspend fun getExamples(
@Query("example_id") previousPageLastExampleId: Int?
): List<ExampleModel>
}
各メソッド名にsusupend装飾子がついているのは、後述しますが非同期処理をCorutinesによって実現しているためです。
また、今回これらのインターフェースの実体化はアプリケーションを通してただ一つのみであるので、以下のようにApplicationクラスで定義して見るとシングルトンを実現できます。
class ExampleApplication : Application() {
・ ・ ・
private val exampleApi: ExampleApi by lazy {
Retrofit.Builder()
.baseUrl(Constants.SERVER_BASE_URL)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
.client(HttpClient.httpClient)
.build()
.create(ExampleApi::class.java)
}
・ ・ ・
}
HttpClientは以下のように定義しました。
object HttpClient {
val httpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(Interceptor { chain ->
val original = chain.request()
// header
val request = original.newBuilder()
.header("Accept", "application/json")
.method(original.method(), original.body())
.build()
return@Interceptor chain.proceed(request)
})
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
}
HttpClientを定義しておくことで、複数のRetrofitビルダーのクライアントとして共通で指定できます。
それと、ところどころでby lazy { }
のような記述がありますが、これは遅延初期化といって、インスタンスが生成された後に中身のプロパティを初期化できるという便利な言語仕様なので覚えておくとお得感あります。
Domain層の実装
Model層の実装
Model層では、サーバーとの通信を行うに当たって、やり取りするデータの型を定義してあげます。
命名には、Entityなどがあるようですが、今回はModelという名前を使うことにしますね。
Kotlinにはdata classというプロパティのみを持ち、メソッドを持たないクラスがありますが、ここではこいつを使ってやります。ちなみにゲッタ/セッタは定義しなくても利用可能なので安心してください。
公式に説明があるので、詳しくはこちらで...
dataクラス公式リファレンス
特に,copy()関数が後々効いてくることがあるので、非常に便利です。
話が逸れましたが、以下に例のソースコードを示します。
data class ExampleModel(
@SerializedName("example_id")
val id: Int,
@SerializedName("example_url")
val url: String,
@SerializedName("example_title")
val title: String,
@SerializedName("example_location")
val location: String,
@SerializedName("example_date")
val date: String
)
SerializableNameで各プロパティに割り当てるレスポンスの要素を指定します。
この型を単位としてサーバーとのAPI通信を行います。
Repository層の実装
Repository層では,APIメソッドを呼び出し、データ取得/送信を行います。
今回、まずは以下のようにRepositoryインターフェースを定義して、抽象化してしまいます。
このインターフェースを実装した実体化クラスを複数用意することで、サーバー内のデータを取得する場合やデータベースからデータを取得する場合、手元で作成したデータでとりあえず動かす場合の切り替えが可能となります。
以下に、抽象化クラスの例を示します。
interface ExampleRepository {
suspend fun getExamples(previousPageLastExampleId: Int?): List<ExampleModel>
}
続いて、このクラスを実装した例を示します。
今回はサーバーと通信を行うため、Remoteをクラス名の先頭につけて強調します。
なるべく詳しい命名をすることで、他の人がそのクラス名を見ただけで何をやろうとしているのか見当をつけやすくなるので、チーム開発をするときには意識したい部分ですね!
class RemoteExampleRepositoryImpl(private val api: ExampleApi) : ExampleRepository {
override suspend fun getExamples(previousPageLastExampleId: Int?): List<ExampleModel> = api.getExamples(previousPageLastExampleId)
}
この返り値を直接=
でしてする方法はKotlinライクな記法なので、積極的に利用するとスマートなコードがかけるようになります。
また、今回DIはコンストラクタインジェクションで統一しており、ここではAPIインスタンスを渡しています。
UseCase層の実装
UseCaseでは、画面単位で必要となるデータを各種Repositoryから取得/送信しています。
今回のアプリでは、ViewとUseCaseを一対一対応させています。
class ExampleUseCase(
private val ExampleRepository: ExampleRepository,
private val fooRepository: FooRepository,
private val barRepository: BarRepository
) {
suspend fun getExamples(
previousPageLastExampleId: Int?
): FetchResult<List<ExampleModel>> = try {
FetchResult.Success(exampleRepository.getExamples(previousPageLastExampleId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
FetchResult.Error(e)
}
・ ・ ・
}
複数のRepositoryをインジェクションすることで、一つの画面に必要となるデータをこれらを通じて集めます。
エラーハンドリングを行なっている部分でFetchResult.Success()
やFetchResult.Error()
等の記述がありますが、これはCorutinesのFetchResultクラスとは異なり、こちら側で定義したものです。
内容を記すと、
sealed class FetchResult<T> {
class Success<T>(val value: T) : FetchResult<T>()
class Error<T>(val exception: Exception) : FetchResult<T>()
}
として宣言し、同名クラスとの重複を防ぐために、外部からの継承を制限するsealed装飾子を付与します。
また、成功と失敗の二つの状態を定義します。
ここで様々なModel型に対応するためにジェネリクスを用いています。これによって、FetchResultクラスを用いて通信処理の成功/失敗の状態を共通で記述することができるようになります。
sealedクラスとジェネリクスの公式リファレンスは以下のようになっています。
sealedクラス公式リファレンス
ジェネリクス公式リファレンス
Presentation層の実装
View層の実装
View層ではユーザーからの入力と画面への出力を受け付けます。
今回はFragmentでの実装例を示します。
構成としては、RecyclerViewのみが乗ったシンプルな構成です。
class ExampleFragment : Fragment(),
ExampleView,
SwipeRefreshLayout.OnRefreshListener {
private lateinit var binding: FragmentExampleBinding
private lateinit var presenter: ExamplePresenter
private lateinit var adapter: ExampleRecyclerViewAdapter
override val scope = LifecycleScope(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
presenter = ExamplePresenter(this, requireContext())
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_event_notification,
container,
false
)
binding.recyclerViewExample.layoutManager = LinearLayoutManager(context)
adapter = ExampleRecyclerViewAdapter(
presenter::onExampleItemClicked
)
binding.recyclerViewExample.adapter = adapter
binding.swipeRefreshLayoutExample.setOnRefreshListener(this)
binding.recyclerViewExample.addOnScrollListener(ScrollerListener(presenter))
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
presenter.onViewCreated()
}
override fun onDestroyView() {
presenter.onDestroyView()
binding.recyclerViewExample.clearOnScrollListeners()
super.onDestroyView()
}
・ ・ ・
}
基本的なライフサイクルイベントとして以下のメソッドをオーバーライドしてカスタマイズしています。
- onCreate - Fragmentが生成されたときにライフサイクルを通して扱う値を初期化する
- onCreateView - 画面を生成する際に必要な処理を行う
- onViewCreated - 画面生成後の処理
- onDestroyView - 画面破棄時に必要な処理を行う
このうち、onCreate, onViewCreatedではスーパーメソッドを呼び出した後にカスタムする処理を記述し、onDestroyViewではスーパーメソッドを呼び出す前に記述します。
Presenterの実装
PresenterはViewに対して内部制御を担当し、画面への表示等を指示する。
具体的には、ユーザーアクションをトリガーとしてデータの取得/送信を行なったり、その結果を画面に反映するなどの役割があります。
class ExamplePresenter(
private var view: ExampleView?,
private var context: Context,
private val useCase: ExampleUseCase =
ExampleUseCase(
context.exampleApplication.ExampleRepository
),
private val lastExampleIdStore: LastExampleIdStore = LastExampleIdStore(
null
)
) {
fun onViewCreated() {
refreshExampleList()
}
fun onDestroyView() {
view = null
}
private fun refreshExampleList() {
val nonNullView = view ?: return
nonNullView.bindLaunch {
when (val result = fetchExample(null)) {
is FetchResult.Success -> showExampleList(result.value)
is FetchResult.Error -> showError(result.exception)
}
}
}
private suspend fun fetchExample(lastExampleId: Int?): FetchResult<List<ExampleModel>> =
useCase.getExamples(lastExampleId)
private fun showError(e: Exception) {
view?.showToast(Exceptions.map(e).getMessage(context))
}
・ ・ ・
fun onExampleItemClicked(exampleModel: ExampleModel) {
view?.openExampleDetailPage(exampleModel)
}
}
Presenterクラスには、Viewクラスのライフサイクルイベントに対応したメソッドonViewCreated(), onDestroyView()が定義されており、それぞれView側の同名メソッドが呼び出された時に必要となる内部処理を担当する。
また、Viewとの相互依存を解決するために、Presenter側からViewを操作する際には、Viewインスタンスを直接握るのではなく、抽象化クラスを握ることにする。
したがって、Viewインターフェースには、画面への反映処理メソッドが定義される。
interface ExampleView : LifecycleScopeSupport {
fun showExampleList(examples: List<ExampleModel>)
fun showToast(message: String)
fun openExampleDetailPage(exampleModel: ExampleModel)
}
これらをViewに実装すると
override fun showExampleList(examples: List<ExampleModel>) {
adapter.updateAll(examples)
}
override fun showToast(message: String) {
val toast = Toast.makeText(
this.activity,
message,
Toast.LENGTH_SHORT
)
toast.show()
}
override fun openExampleDetailPage(exampleModel: ExampleModel) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(exampleModel.url))
startActivity(intent)
}
Storeの実装
Storeは、状態情報などを保持する役割があります。
今回は、最後に取得したExampleModelのidを保存する例を示します。
data class LastExampleIdStore(
var lastExampleId: Int?
)
え...ただ値を持ってるだけじゃん...と思いますが、Presenterそのものはあくまで、「Viewの内部処理」を受け持つ存在であって、状態情報を持つべきではないという考えに基づいています。
いくら少量のコードであっても、「役割に応じてきちんとクラス分けをすることを心がける」
今回のプロジェクトで学んだことの中でも重要な考え方です。
レイアウトについて
長いアーキテクチャの説明でしたが、Androidアプリ開発に欠かせないもう一つの技術XMLについてのお話です。
上記のExampleFragmentとその上に乗っているRecyclerViewのアイテムのレイアウトを例を載せておきます。
細かい説明は省略します...
Fragmentのレイアウト
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh_layout_example"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="60dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view_example"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="60dp"
android:scrollbars="vertical"
android:background="@color/white" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
</layout>
RecyclerViewのアイテムのレイアウト
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable name="exampleModel"
type="jp.co.techbowl.techstation.domain.model.ExampleModel"/>
<variable
name="onExampleItemClick"
type="android.view.View.OnClickListener"/>
</data>
<androidx.cardview.widget.CardView
android:id="@+id/example_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="12dp"
app:cardCornerRadius="10dp"
android:onClick="@{onExampleItemClick}">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp">
<TextView
android:id="@+id/example_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="16dp"
android:text="@{exampleModel.title}"
android:textSize="22sp"
android:textColor="@color/black"
android:minLines="2"
android:maxLines="2"
android:background="@color/example_gray"
android:ellipsize="end"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<ImageView
android:id="@+id/example_date_icon"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:src="@drawable/ic_date"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/example_title"
app:layout_constraintBottom_toBottomOf="@+id/example_date"/>
<TextView
android:id="@+id/example_date"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{exampleModel.date}"
android:textSize="18sp"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toBottomOf="@+id/example_title"
app:layout_constraintStart_toEndOf="@+id/example_date_icon"
app:layout_constraintEnd_toEndOf="parent"/>
<ImageView
android:id="@+id/example_location_icon"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:src="@drawable/ic_location"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/example_date_icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="@+id/example_location"/>
<TextView
android:id="@+id/example_location"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{exampleModel.location}"
android:textSize="18sp"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toBottomOf="@+id/example_date"
app:layout_constraintStart_toEndOf="@+id/example_location_icon"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>
レイアウトを構成するに当たって、特に意識すべきことをまとめて見ました。
- 複雑なViewを扱う際にはConstrainLayoutが有効
- コンポーネントが大量にある場合には、多様な画面の大きさに対応するために中央にしたいものの左右のlayout_contraint属性をparent指定し、他のコンポーネントはこのコンポーネントの左右を参照する
- DataBindingを活用することでKotlinプログラム上のプロパティを選択することができる
- クリックイベントをDataBindingで指定し、Kotlinプログラムの同名メソッドを呼び出し可能
- paddingはクリックイベントの範囲を大きくしたいときなどに有効、それ以外は基本marginを使う
非同期処理について
アーキテクチャの次に、プログラム全体の構造に影響を与えうる技術、非同期処理についてです。
Androidアプリ開発でも、色々な非同期処理手法があるようですが、例えば以下のようなものがあるそうです。
- Thread
- Handler
- Looper
- AsyncTask系
- Fragmentの応用
- RxJava
- Corutines
- Promise
僕は正直、扱ったことの無いものの方が多いです...(中には、これヤバいだろ...って思うものも)
今回は、折角Kotlin使ってるし、ナウい非同期処理を書こうということで、Corutinesを採用しました。
以下の記事のコードを参考に実装しました。
かなり便利なプログラムなので、是非参考にしてください...
Android Lifecycle + Kotlinx Coroutines
基本的には、
LifecycleScopeSupport
をオーバーライドして
override val scope = LifecycleScope(this)
のようにライフサイクルを持つViewに対してスコープを合わせます。
Presenter側でデータの取得/送信処理を走らせる際に、非同期処理をかける必要があるので、
val nonNullView = view ?: return
nonNullView.bindLaunch {
when (val result = fetchExample(null)) {
is FetchResult.Success -> showExampleList(result.value)
is FetchResult.Error -> showError(result.exception)
}
}
のように, nonNullView.bindLanch { }
として、非同期にしたい処理を記述します。
また、非同期処理を行うメソッドにはsuspend
装飾子をつけて宣言し、中断可能なメソッドとしなければなりません。
非同期処理を行うメソッドを呼び出すメソッドについても同様にsusupend
をつける必要があります。
エラーハンドリングについて
通信処理などを行うと何かと例外処理が必要になってきますよね。
今回、僕は以下の記事を参考に実装しました。
Androidアプリにおけるエラー時のユーザーフィードバックについて
そして、実際に実装してみたのがこちら
sealed class Exceptions {
abstract fun getMessage(context: Context): String
data class ApiErrorFeedback(val message: String, val code: Int) : Exceptions() {
override fun getMessage(context: Context): String = message
}
data class ApplicationErrorFeedback(@StringRes val resId: Int) : Exceptions() {
override fun getMessage(context: Context): String = context.getString(resId)
}
companion object {
fun map(throwable: Throwable): Exceptions = when (throwable) {
is UnknownHostException ->
ApplicationErrorFeedback(R.string.error_unknown_host)
is ConnectException ->
ApplicationErrorFeedback(R.string.error_connection)
is SocketTimeoutException ->
ApplicationErrorFeedback(R.string.error_socket_time_out)
is HttpException -> {
try {
throwable.response()?.errorBody()?.let {
ApiErrorFeedback(
Gson().fromJson(it.string(), ExceptionModel::class.java).message,
throwable.code()
)
} ?: throw JsonSyntaxException(Throwable())
} catch (e: JsonSyntaxException) {
when (throwable.code()) {
404 -> ApplicationErrorFeedback(R.string.error_not_found)
406 -> ApplicationErrorFeedback(R.string.error_not_acceptable)
408 -> ApplicationErrorFeedback(R.string.error_request_time_out)
409 -> ApplicationErrorFeedback(R.string.error_conflict)
else -> ApplicationErrorFeedback(R.string.error_transmission)
}
}
}
else -> ApplicationErrorFeedback(R.string.error_transmission)
}
}
}
このコードのいいところは、全ての例外をExceptionsとしてラッピングしながらも、内部で例外の種類を判別し、結果として文字列が返ってくるところです。
したがって、Presenterからこんな感じで利用できます。
private fun showError(e: Exception) {
view?.showToast(Exceptions.map(e).getMessage(context))
}
あとは、返ってきた文字列をViewに渡してあげて、画面にToastしてあげれば、エラーハンドリング達成というわけです。
GitHubについて
実は、今回最も大切なことを教わったのが、GitHubに関してだと思います。
なんにせよ、チーム開発を行うに当たっては、これほど必須のツールはないでしょう。
エンジニアとして、必要な心構えというものをGitHubの使い方を通して学びました。
特に、重視すべきポイントはPR(プル・リクエスト)の作り方です
僕がこのプロジェクトを通して学んだ中で、大切だなぁと思ったPRの作り方を列挙したいなと思います。
- PRの粒度はなるべく細かく、開発時は1画面レベルで、修正時は1機能レベルで切って行くと良さそう。
- commitの粒度も細かく意味を持たせる、レビュワーにわかりやすいcommitメッセージを心がける
- pushするときは含まれるcommitに誤りが含まれてないか確認してから
- 連続した作業でcommitを作り、途中で関係のない変更を含まないようにする
- PRの目的外のコードの修正を行わない
- レビューしてもらえるPR作りを心がける
一見、「当たり前じゃん...」となりますが、結構疲れている時や期日間近になると、疎かになりがちです。
心に刻んで意識しましょう(自戒)
GitHubに関しては、PM編でも触れようと考えているので、エンジニア視点での内容は以上とします。
まとめ
アーキテクチャから言語仕様、GitHubまでかなり幅の広い内容をダイジェストでお送りしましたが、「実際にアプリを作る」にはこれまでにも幅広い知識とスキルが求められます。
ある程度開発を積んできたつもりではいましたが、プロの方から教えていただくと、学習量って凄まじくて、ここには書き足りないぐらいです...
でも、よくよく考えるとそういった方に教えてもらうことによって、自力では見つけ得なかった知識にたどり着けるってすごく素敵ですよね。
ご指導してくださったメンターさん方には感謝しても足りない程です。
あと、ここまでこの記事を読んでくださった方、本当にありがとうございます。
学んだ量が多すぎて、アウトプットするために言語化する作業が追いつかない程なんですが、それぐらい得られるものが多すぎました、ホントに...
Qiita書いてるの楽しいけど結構疲れる...
同じプロジェクトの他のメンバーも記事を書いてくれるようなので、不足する部分はきっと補ってくれる...(はず!)
それでは、PM編でまた会いましょう!!