今年の9月から、Androidアプリ開発をすることになりました
しかしながら、前職は組み込みソフト開発でC言語。Kotlinどころかクラスアーキテクチャも知りません。
ということで、超簡単なプロジェクトを題材にAndroidアーキテクチャを考えてみました!
ご指摘お待ちしております
題材
- 中央のフォームに名前を入力する
- REGISTERボタンを押す
- 念のため端末のストレージに入力された名前を保存する
- 「Hello 〇〇!」と表示する
たったこれだけのシンプルなものです
挨拶だけはしてくれるので自粛中の寂しいクリスマスにでもどうぞ
参考にした素晴らしい記事:
Webアプリケーション開発者から見た、MVCとMVP、そしてMVVMの違い
iOS/Androidアプリエンジニアが理解すべき「Model」の振る舞い
ソースコードで理解するクリーンアーキテクチャ
代表的なアーキテクチャで実装してみた
アーキテクチャのない世界
class MainActivity : AppCompatActivity() {
private val KEY_USERNAME = "key_user_name"
private val DEFAULT_USERNAME = "John Doe"
private lateinit var preferences: SharedPreferences
private lateinit var textView: TextView
private lateinit var editView: EditText
private lateinit var buttonView: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
preferences = EncryptedSharedPreferences.create( // 端末内保存のためのインスタンスを用意する
"secret_shared_prefs",
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
textView = findViewById(R.id.text_view) // ViewとActivityを紐づける
editView = findViewById(R.id.edit_view)
buttonView = findViewById(R.id.button_view)
buttonView.setOnClickListener { // ボタンがクリックされたときの処理
val editor = preferences.edit()
editor.putString(KEY_USERNAME, editView.text.toString())
editor.apply()
// 画面へ反映する
textView.text = "Hello ${preferences.getString(KEY_USERNAME, DEFAULT_USERNAME)}!"
}
}
}
全ての処理がActivity上に書かれています
正直このくらいの小規模なプロジェクトなら見やすくもあるのですが、まあ普通分けることになるでしょうね...
単体テストも全くできそうにありませんね
Model-View-Controller
重複するところはガンガン省いていきます
ポイントは下記
- EncryptedSharedPreferenceへの読み書きをUserModelに切り出し
- ModelのI/FはCRUD的な粒度で
- ApplicationクラスでUserModelのインスタンスを保持
Activityは破棄される可能性があるのでModelのインスタンスを持つには不向きだと思いました
今回は読み書きが同期的なので普通に関数コールですが、
非同期の場合はコールバックかObserverで実装することになるのかなと思います
ViewとControllerは分けていません
というのも、そもそもActivityがViewとControllerを兼ねたような微妙な存在であるためです
iOSだとそのままViewControllerという名前がついているくらいですし。やろうと思えばできるんでしょうけど...
シンプルでわかりやすくはあるのですが、Activityの役割はまだまだ大きい印象です
(AndroidっぽくUserRepositoryにしておけば良かった)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
userModel = CustomApplication.instance!!.userModel
textView = findViewById(R.id.text_view)
editView = findViewById(R.id.edit_view)
buttonView = findViewById(R.id.button_view)
buttonView.setOnClickListener {
userModel.setName(editView.text.toString())
val displayText = "Hello ${userModel.getName()}!"
textView.text = displayText
}
}
interface UserInterface {
fun setName(name: String): Unit
fun getName(): String
}
class UserModel(context: Context): UserInterface {
private val KEY_USERNAME = "key_user_name"
private val DEFAULT_USERNAME = "John Doe"
private var preferences: SharedPreferences =
EncryptedSharedPreferences.create(
"secret_shared_prefs",
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
override fun setName(name: String) {
val editor = preferences.edit()
editor.putString(KEY_USERNAME, name)
editor.apply()
}
override fun getName(): String {
val name = preferences.getString(KEY_USERNAME, null)
return if (name == null) {
DEFAULT_USERNAME
} else {
name
}
}
}
class CustomApplication: Application() {
lateinit var userModel: UserModel
companion object {
@get:Synchronized var instance: CustomApplication? = null
private set
}
override fun onCreate() {
super.onCreate()
userModel = UserModel(applicationContext)
instance = this
}
}
Model-View-Presenter
- 入出力系を扱うView
- データを扱うModel
- ビジネスロジックを扱うPresenter
という3層に分割します
先ほどのMVCと異なるところはいくつかあります
- PresenterがViewとModel双方への参照を持ち、両者を分離する
- ContractでPresenterとViewのやりとりを規定する
Viewが純粋に入力と出力のハンドリングだけになるので役割的にはシンプルな気がします
ViewとPresenter感がInterfaceで繋がれているので、Viewを差し替えてテストできるようになりました
しかし、Contractが必要だったりとややアーキテクチャとしてはやや重い印象です
Activityが破棄されてしまっていないかPresenterが気にする必要もありそうですし。
private lateinit var _presenter: MainContract.Presenter
~略~
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
userModel = CustomApplication.instance!!.userModel
MainPresenter(userModel, this) /* PresenterにViewとModelを紐付ける */
textView = findViewById(R.id.text_view)
editView = findViewById(R.id.edit_view)
buttonView = findViewById(R.id.button_view)
buttonView.setOnClickListener {
_presenter.editName(editView.text.toString()) /* Presenterにユーザ入力を渡す */
}
}
override fun <T> setPresenter(presenter: T) {
_presenter = presenter as MainContract.Presenter
}
override fun updateTextView(name: String) {
textView.text = name
}
override fun isActive(): Boolean {
return lifecycle.currentState == Lifecycle.State.RESUMED
}
class MainPresenter(model: UserInterface, view: MainContract.View) : MainContract.Presenter {
private val _model: UserInterface = model
private val _view: MainContract.View = view
init {
_view.setPresenter(this)
}
override fun editName(name: String) {
_model.setName(name)
if (_view.isActive()) {
_view.updateTextView("Hello ${_model.getName()}!")
}
}
}
interface MainContract {
interface View {
fun <T> setPresenter(presenter: T)
fun updateTextView(name: String)
fun isActive(): Boolean
}
interface Presenter {
fun editName(name: String)
}
}
Model-View-ViewModel
MVPで画面とロジックの切り分けができるようになったものの、双方向の参照関係が残っています
Activityはメモリ不足などの要因であっさり破棄されるので、もっと疎にしたいところです
そこで出てくるのがViewModelで、Google公式でもおすすめされております
このViewModelの役割は、「表示するためのデータの一時的な保存と加工」です
ViewであるActivityはAndroidアーキテクチャとべったりでテストしにくいので、
可能な限りViewModelに責任を移し、ViewとViewModel間の依存性を薄く、一方向にするのが目的かと思います
(実際ViewModelからViewを意識することはほとんどない)
特にViewModelからViewへの参照は基本的にNGで、画面へのデータ表示はLiveData(ライフサイクル付きObserver)を使います
DataBindingを使うとさらにコード上での依存性を減らすことができますが、必須ではないと思います
役割的にあくまで画面の前段なので、ビジネスロジックは別で考えるんでしょうね
private lateinit var viewModel: MainViewModel
~ 略 ~
override fun onCreate(savedInstanceState: Bundle?) {
~ 略 ~
buttonView.setOnClickListener {
viewModel.updateUserName(editView.text.toString())
}
viewModel = ViewModelProvider(this, ViewModelProvider.NewInstanceFactory())
.get(MainViewModel::class.java)
// = ViewModelProvider(this, LoginViewModelFactory()).get(MainViewModel::class.java)
// と書いてもいいが、一応古い書き方であるようだ
viewModel.getUserName().observe(this, Observer { value ->
textView.text = "Hello ${value}!"
})
}
class MainViewModel: ViewModel() {
private val userName = MutableLiveData<String>()
fun getUserName(): LiveData<String> {
return userName
}
fun updateUserName(name: String) {
CustomApplication.instance!!.userModel.setName(name)
userName.value = CustomApplication.instance!!.userModel.getName()
// 別スレッドから値をセットする場合はuserName.postValue()を使う
}
}
そしてCleanArchitectureへ...
先ほどのMVVMだと、画面周りの依存性は薄くなりましたが、それ以外は言及されていません
ということでさらなる最強アーキテクチャを目指してCleanArchitectureを実装してみました
(この辺かなり怪しいので参考としてどうぞ)
この同心円上の図をみたことがあるでしょうか。大事なことは二つ
- 内側から外側への依存性は可能な限り薄くする。外側が内側に依存するのはOK
- UseCaseが入力→出力への流れを作る
UseCases
data class ChangeUserInputData ( /* Use Case Input Portに相当 */
var name: String
)
data class ChangeUserOutputData ( /* Use Case Output Portに相当 */
var name: String
)
interface IChangeUserUseCase { /* Use Case Interactorに相当 */
fun setPresenter(presenter: IMainPresenter)
operator fun invoke(user: ChangeUserInputData)
}
class ChangeUserUseCase(rep: UserRepository) : IChangeUserUseCase {
private val userRepository: IUserRepository = rep
private lateinit var mainPresenter: IMainPresenter
override fun setPresenter(presenter: IMainPresenter) {
mainPresenter = presenter
}
// 1クラス1UseCaseが基本。各UseCaseはビジネスロジック(やりたいこと)を表現する
// この場合、名前を保存して、ユーザネームとして表示する、というこのアプリで実現したいことが表現されている
override fun invoke(input: ChangeUserInputData) {
userRepository.saveUserName(input.name)
val name = ChangeUserOutputData(userRepository.getUserName())
mainPresenter.updateUserName(name)
}
}
Presenter (ViewModel)
interface IMainPresenter {
fun setUserName(name: String)
fun updateUserName(name: ChangeUserOutputData)
}
今回は規模が小さいのでPresenterのInterfaceをViewModelで実装していますが、
ViewModelとPresenterを分ける方が正しそうな感じはします
// 外側(Presenter)から内側(UseCases)に依存するのはアリ
class MainViewModelFactory(private val changeUserUseCase: IChangeUserUseCase): ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.getConstructor(IChangeUserUseCase::class.java).newInstance(changeUserUseCase)
}
}
class MainViewModel(changeUserUseCase: IChangeUserUseCase): ViewModel(), IMainPresenter {
private val greetingMessage = MutableLiveData<String>()
private val changeUser = changeUserUseCase
fun getUserName(): LiveData<String> {
return greetingMessage
}
override fun setUserName(name: String) {
changeUser(ChangeUserInputData(name))
}
override fun updateUserName(name: ChangeUserOutputData) {
greetingMessage.value = "Hello ${name.name}!" // 表示するための加工はここでやる
}
}
override fun onCreate(savedInstanceState: Bundle?) {
~ 略 ~
buttonView.setOnClickListener {
viewModel.setUserName(editView.text.toString())
}
val changeUserUseCase = CustomApplication.instance!!.changeUser
val viewModelFactory = MainViewModelFactory(changeUserUseCase)
viewModel = viewModelFactory.let {
ViewModelProvider(this, it).get(MainViewModel::class.java)
}
changeUserUseCase.setPresenter(viewModel)
viewModel.getUserName().observe(this, Observer { value ->
textView.text = value
})
}
まとめ
MVVMまではともかく、CleanArchitectureは正しく実装できている自身はありません...
が、なんとなくどういうことをやらなければいけないか、という感触は得られたような気がします
CleanArchitectureもなかなか重たく、中規模アプリまではMVVMがシンプルでいいかなと思いました
もう少しちゃんと理解したい...