Android
Kotlin
MVP


初めに

Androidアプリを作成する時にクラス分けをしっかりしていますか?GooglePlayストアの内の小さいアプリ並のアプリを制作する時に、クラスがぐちゃぐちゃになっていますか?整理ができないそんなあなたにGoogleさんのandroid-architectureで設計することをお勧めします。ここでは、実際のアプリ(TODOアプリ)を使ったコード・クラス設計が読めます。

因みに私はKotlinを一切書いたことが一度もなかったのでここで勉強しました。(Kotlinの日本語ドキュメント:作った人に感謝)

パッケージ分けもしている。

2bf28c7f6d9202562e499c9851802da7.png

無題.png


参考記事(全て読むことをお勧めします)

Googleさんの記事(実際にTODOアプリを使って解説)android-architecture

このページの中のtodo-mvp-kotlinをやります。todo-mvp-kotlinのREAMDME.mdはtodo-mvpのREADME.mdと合わせて読むとよし。


MVPって何

取り合えず、Googleさんが参考記事としているwikiを見る。


  • Model:表示されるべきデータを定義するインターフェース

  • View:データ(モデル)を表示し、ユーザのイベントをPresenterにルーティング(データを渡すのをどのVeiwにするか決める、ここでは自分)してそのデータを処理するパッシブインターフェース(ルーティングプロトコルのやりとりが必要ないインターフェース)

  • Presenter:モデルとビューに作用する。モデルからデータを取得し、ビューに表示するためにフォーマットする。


GoogleさんのTODOアプリの実装はどうなっている?


アプリのインストール

Open a sample in Android Studio

# アプリをcloneしたいディレクトリ上で

~AndroidStudioProjects $ git clone git@github.com:googlesamples/android-architecture.git

checkoutする。

~AndroidStudioProjects $ git checkout todo-mvp-kotlin

開く

openproject.png


アプリの実行

ビルドすると、Gradleにてエラーが起こるかもしれません。compileがサポートされなくなった為です。他のようにimplementationapiを使いましょう。(他にもエラーが出ますが同じように解決します)

builderror.png

GalaxyS8で撮影、左の透明なバーはGalaxyの機能

無題.png

チェックボックスを押すとLogcatでログを確認できます。

018a85992eb495298bf70162a4ad5004.png


実装内容

https://github.com/googlesamples/android-architecture/tree/todo-mvp/#designing-the-app


  • コンタクトクラス(contact class):ViewとPresenterの間のやり取りを定義する

  • アクティビティ(Activity):FragmentとPresenterを作成する

  • フラグメント(Fragment):Viewインターフェースを実装する(implement)

  • プレゼンター(Presenter):Presenterインターフェースを実装する(implement)

TasksActivityがメインアクティビティ。その他コンタクトクラス、アクティビティ、フラグメント、プレゼンターごとにパッケージ分けしている。パッケージの命名は画面、モデルごと?

2bf28c7f6d9202562e499c9851802da7.png

この画面を例にします。

423992c2cea74c999ef4857d733dce2d.png

Activityはxmlの指定、その他Activityに依存するコードを書く


StatisticsActivity.kt

class StatisticsActivity : AppCompatActivity() {

private lateinit var drawerLayout: DrawerLayout

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Fragmentの作成
setContentView(R.layout.statistics_act)

// Set up the toolbar.
setupActionBar(R.id.toolbar) {
setTitle(R.string.statistics_title)
setHomeAsUpIndicator(R.drawable.ic_menu)
setDisplayHomeAsUpEnabled(true)
}

// Set up the navigation drawer.
drawerLayout = (findViewById<DrawerLayout>(R.id.drawer_layout)).apply {
setStatusBarBackground(R.color.colorPrimaryDark)
}
val navigationView = findViewById<NavigationView>(R.id.nav_view)
setupDrawerContent(navigationView)

val statisticsFragment = supportFragmentManager
.findFragmentById(R.id.contentFrame) as StatisticsFragment?
?: StatisticsFragment.newInstance().also {
replaceFragmentInActivity(it, R.id.contentFrame)
}

// Presenterの作成
// Repository(Model)とFragmentの指定
StatisticsPresenter(
Injection.provideTasksRepository(applicationContext), statisticsFragment)
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
// Open the navigation drawer when the home icon is selected from the toolbar.
drawerLayout.openDrawer(GravityCompat.START)
return true
}
return super.onOptionsItemSelected(item)
}

private fun setupDrawerContent(navigationView: NavigationView) {
navigationView.setNavigationItemSelectedListener { menuItem ->
if (menuItem.itemId == R.id.list_navigation_menu_item) {
NavUtils.navigateUpFromSameTask(this@StatisticsActivity)
}
// Close the navigation drawer when an item is selected.
menuItem.isChecked = true
drawerLayout.closeDrawers()
true
}
}
}


ContractはFragmentとPresenterのインターフェースで、FragmentとPresenter間でコールバック処理を行うためにFragmentとPresenterで使うメソッドを宣言するだけです。こうすることで実装するメソッドをContractクラスを見ただけでスムーズに伝わるメリットもあります。クラス図のメソッドをここに書くっといってもいいかもしれません。


StatisticsContract.kt

interface StatisticsContract {

interface View : BaseView<Presenter> {
val isActive: Boolean

fun setProgressIndicator(active: Boolean)

fun showStatistics(numberOfIncompleteTasks: Int, numberOfCompletedTasks: Int)

fun showLoadingStatisticsError()
}

interface Presenter : BasePresenter
}


StatisticsContract.Viewを実装しています。基本的に値をViewに反映させるだけです。値はPresenterから渡されます。Fragmentが読み込まれるとpresenter.start()が実行され初期読み込み(タスクの統計情報読み込み)が始まります。


kotlinStatisticsFragment.kt

class StatisticsFragment : Fragment(), StatisticsContract.View {

private lateinit var statisticsTV: TextView

override lateinit var presenter: StatisticsContract.Presenter

override val isActive: Boolean
get() = isAdded

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
val root = inflater.inflate(R.layout.statistics_frag, container, false)
statisticsTV = root.findViewById(R.id.statistics)
return root
}

override fun onResume() {
super.onResume()
presenter.start()
}

/**
* プログレスインジケータの表示する値をstatisticsTVにセット
*/

override fun setProgressIndicator(active: Boolean) {
if (active) {
statisticsTV.text = getString(R.string.loading)
} else {
statisticsTV.text = ""
}
}

/**
* 統計情報の表示する値をstatisticsTVにセット
*/

override fun showStatistics(numberOfIncompleteTasks: Int, numberOfCompletedTasks: Int) {
if (numberOfCompletedTasks == 0 && numberOfIncompleteTasks == 0) {
statisticsTV.text = resources.getString(R.string.statistics_no_tasks)
} else {
val displayString = "${resources.getString(R.string.statistics_active_tasks)} " +
"$numberOfIncompleteTasks\n" +
"${resources.getString(R.string.statistics_completed_tasks)} " +
"$numberOfCompletedTasks"
statisticsTV.text = displayString
}
}

/**
* エラー情報(Error loading statistics)をstatisticsTVにセット
*/

override fun showLoadingStatisticsError() {
statisticsTV.text = resources.getString(R.string.statistics_error)
}

companion object {

fun newInstance(): StatisticsFragment {
return StatisticsFragment()
}
}
}


ロジック部分担当。ここでは、DBから値を取得して、Fragmentのメソッドコールバックで呼び出しています。


StatisticsPresenter.kt

class StatisticsPresenter(

val tasksRepository: TasksRepository,
val statisticsView: StatisticsContract.View
) : StatisticsContract.Presenter {

init {
//Presenterを指定
statisticsView.presenter = this
}

override fun start() {
loadStatistics()
}

private fun loadStatistics() {
// ロード中と表示
statisticsView.setProgressIndicator(true)

// The network request might be handled in a different thread so make sure Espresso knows
// that the app is busy until the response is handled.
EspressoIdlingResource.increment() // App is busy until further notice

// Callbackを与えて処理
tasksRepository.getTasks(object : TasksDataSource.LoadTasksCallback {
override fun onTasksLoaded(tasks: List<Task>) {
// We calculate number of active and completed tasks
// アクティブなタスクと完了したタスクの数を計算する
val completedTasks = tasks.filter { it.isCompleted }.size
val activeTasks = tasks.size - completedTasks

// This callback may be called twice, once for the cache and once for loading
// the data from the server API, so we check before decrementing, otherwise
// it throws "Counter has been corrupted!" exception.

// このコールバックは、キャッシュ用に1回、
// サーバAPIからのデータを1回ロードするために1回ずつ呼び出されるため、
// デクリメントする前にチェックする必要があります。
// それ以外の場合は例外。
if (!EspressoIdlingResource.countingIdlingResource.isIdleNow) {
EspressoIdlingResource.decrement() // Set app as idle.
}
// The view may not be able to handle UI updates anymore

if (!statisticsView.isActive) {
return
}

statisticsView.setProgressIndicator(false)
statisticsView.showStatistics(activeTasks, completedTasks)
}

override fun onDataNotAvailable() {
// The view may not be able to handle UI updates anymore

if (!statisticsView.isActive) {
return
}
statisticsView.showLoadingStatisticsError()
}
})
}
}



あれ?何かこの実装おかしくない?

ドロワーで画面遷移をする時のアニメーションが何か違う。

https://material.io/design/components/navigation-drawer.html

コードをよく見ると、Activity同士が画面遷移をしていることが分かる。で、そっくりなlayoutを2つ作っている。これって、冗長じゃない?

tasks_act.xml

tasks.png

statistics_act.xml

statistics.png


解決策

tasks関連(TasksActivity.kt,tasks_act.xml,TasksFragment.kt,....)

statistics関連(StatisticsActivity.kt,statistics_act.xml,StatisticsFragment.kt,....)



共通(TasksMainActivity.kt,main_act.xml)

tasks関連(TasksFragment.kt,....)

statistics関連(StatisticsFragment.kt,....)

自分「命名って本当にTasksMainでいいの???」誰か教えて


Contranctって何だ?

以下の記事が分かりやすかったです。

Androidアーキテクチャことはじめ ― 選定する意味と、MVP、Clean Architecture、MVVM、Fluxの特徴を理解する


ContractはPresenterとActivity/Fragmentとの間のやりとりを定義したもので、以下のようにインタフェースとして定義します。

また、

TasksContract.Viewが、Activity/FragmentなどのViewモジュールが実装すべきインタフェースで、Presenterから呼ばれることが想定される関数が定義されています。それらの関数の役割は主にUIの操作です。

逆にTasksContract.Presenterは、Presenterモジュールが実装すべきインタフェースで、Activity/Fragmentから呼ばれることが想定される関数が定義されています。 それらの関数はこれまでActivity/Fragmentがやっていたような処理のうち、UIの操作以外、すなわちデータの取得処理を呼び出す、データの保存処理を呼び出すなどの処理です。



StatisticsでMVPを確認する

クラス図1.png


実際作る

取り合えず、作成する画面をadobeXDで作成。

weekcheck.png


パッケージ構成を決める

Native DrawerなどでFragmentが2つ以上必要な場合を考えると...

2bf28c7f6d9202562e499c9851802da7.png

このパッケージ構成では、誤ってActivityを2つ作成してしまう。(TasksActivity.ktStatisticsActivity.ktを作成してしまう。本当はFragmentで画面を切り替えるべきなのに...)開発者が1人なら、パッケージ構成が分かってるかもしれないが、知らない人が後から開発に加わるとActivityをパッケージごとにつくってしまう、もしくはそう解釈してしまうかも。(間違って2つ作ってしまう)

悩んでたら実物を見るのが一番。記事を検索するかgithubで「android mvp」と検索


参考

自分「これmvcだけど、mvpもこんな感じでいいんじゃない?」

https://qiita.com/kobakei/items/e8452d04f6991be3498a

12e01a851f6fc6d03608811e99913a0b.png

http://wannabe-jellyfish.hatenablog.com/entry/2015/08/08/231840

こっちは、mvp

https://github.com/xinghongfei/LookLook/tree/master/app/src/main/java/com/looklook/xinghongfei/looklook

a6a44c82c0004c7ca64c66789cdb09ad.png


MVP + Clean Architecture

https://github.com/googlesamples/android-architecture/tree/todo-mvp-clean/

49ba135ec81d422ea0aff285d798fa20.png

mvp-clean.png


それからどうしたの?

自分のアプリを作成したら、こうなった。

Screenshot_20181205-000318_WeekCkech.jpg

無題.png

e0f72a9043af67fa51d048da82be6544.png

Activity


AddEditTaskActivity.kt

class AddEditTaskActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_edit_task)

// StatusBarの色を変更
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {// Android 5.0 Lollipop
val window = getWindow()
// clear FLAG_TRANSLUCENT_STATUS flag:
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
// add FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS flag to the window
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
// finally change the color
window.statusBarColor = ContextCompat.getColor(this,R.color.colorSub)
}
// Fragmentのセット
val fragment= AddEditTaskFragment()
val manege = supportFragmentManager.beginTransaction()
manege.add(R.id.add_fragment_space,fragment)
manege.commit()
}
}


Contract


PagerDayContract.kt

interface PagerDayContract {

interface View : BaseView<Presenter> {
fun showDaysTasks(taskDataModel: ArrayList<TaskDataModel>)
fun showAddEditTask()
fun showDayTasks()
}

interface Presenter : BasePresenter {
fun loadDaysTasks()
fun completeDaysTask()
fun activateDaysTask()
fun addNewDayTask()
fun clearCompleteTasks()
}
}


Fragment


MainPagerDayFragment.kt

/**

* PagerDayContract.Viewの実装メソッドはPagerDayPresenterから呼ばれることを想定しています。
*
* ビューのクリックリスナーは基本的にこのクラスでセットしますが、ListViewのアダプターは例外です。
* アダプター内でクリックリスナーをセットしています。
*/

class MainPagerDayFragment : Fragment(), PagerDayContract.View {
// セットする変数の宣言
override var presenter: PagerDayContract.Presenter = PagerDayPresenter(this)
lateinit var binding: PagerDayBinding
private lateinit var mContext: Context

override fun showDaysTasks(taskDataModel: ArrayList<TaskDataModel>) {
binding.listView.adapter = TasksAdapter(mContext, taskDataModel, presenter)
}

override fun showAddEditTask() {
var mIntent = Intent(this.mContext, AddEditTaskActivity::class.java)
startActivity(mIntent)
}

override fun showDayTasks() {

}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.pager_day, container, false)
val root = binding.root
binding.presenter = presenter
return root
}

override fun onResume() {
super.onResume()
// タスクのロード、ロジックはプレゼンターでやる
presenter.start()
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Contextの格納
mContext = view.context
}
}


Presenter


AddEditTaskPresenter.kt

class AddEditTaskPresenter(val addEditTaskView: AddEditTaskContract.View) : AddEditTaskContract.Presenter {

override fun getEditTaskData() {
val lists = listOf<Int>(R.id.edit_include_detail, R.id.edit_include_limittime,
R.id.edit_include_notificationtime, R.id.edit_include_weekgroup)
for (list in lists) {
when (list) {
}
}
}

override fun loadTaskConfigEditRow() {
val lists = java.util.ArrayList<AddEditTaskItemModel>()
val model = AddEditTaskItemModel
lists.add(AddEditTaskItemModel(R.id.edit_include_detail, R.drawable.ic_notes_white_24dp, model.EDITTEXT, hintText = "詳細"))
lists.add(AddEditTaskItemModel(R.id.edit_include_limittime, R.drawable.ic_access_time_white_24dp, model.TEXTVIEW, text = "12:00"))
lists.add(AddEditTaskItemModel(R.id.edit_include_notificationtime, R.drawable.ic_notifications_white_24dp, model.SPINNER, spinnerItem = arrayListOf("1H前", "2H前", "12時")))
lists.add(AddEditTaskItemModel(R.id.edit_include_weekgroup, R.drawable.ic_today_white_24dp, model.SPINNER, spinnerItem = arrayListOf("月", "火", "水", "木", "金", "土", "日")))
addEditTaskView.setTaskConfigEditRow(lists)
}

override fun saveTask(model: TaskDataModel, mContext: Context) {
val db = Room.databaseBuilder(mContext, AppDatabase::class.java, "database-name").build()
val task = RoomTask(lastUpdate = Date(),
isChecked = model.isChecked,
detail = model.detail,
limitTime = model.limitTime,
notificationTime = model.notificationTime,
weekGroup = model.weekGroup)

thread {
db.roomTaskDao().insert(task)

val result = db.roomTaskDao().getAll()

result.forEach { it -> Log.d("resultDB", it.toString()) }

addEditTaskView.showTasksMain()
}
}

override fun start() {
loadTaskConfigEditRow()
}

}