前言
從事Android軟體開發也有一陣子了。
對於架構這個設計模式的概念一直都是模糊狀態,因此才有這篇的產生。
簡單來說架構就是將程式碼依照功能切割成各個模組,彼此之間可以互相引用,但不必完全依賴對方。
因此我們可以任意抽換其中一個模組,實現不同的功能。
就目前我所了解的,架構大致上分為三層:
- Presentation
- Business
- Data
首先是Presentation-表達層。以圖形化介面的應用程式來說,這裡指的就是應用程式的畫面,負責顯示結果、處理使用者互動等工作。
再來是Business-業務邏輯層。接收表達層的請求,專注處理應用程式的業務邏輯。
最後是Data-資料層,這一層主要是負責存取資料。當接收到業務邏輯層呼叫時,資料層就會從網路、檔案、或是資料庫等地方取得資料,執行處理之後再回傳。
MVC
MVC全名是Model-View-Controller。
在MVC的設計理念中:
- Model層-就負責處理資料
- View層-顯示結果及處理使用者互動
- Controller層-究責處理業務邏輯。
以下是MVC架構的示意圖:
若對照Android APP的組件,分別如下:
- Model-資料及序列化後的資料模型,來源可能是從檔案、網路、本地資料庫不等
- View-APP的畫面,通常指的是layout.xml文件
- Controller-APP的業務邏輯,通常指的是Activity、Fragment等元件
但是我們實際在開發的時候,往往會受制於layout.xml的功能太弱,導致我們必須將View的處理邏輯寫在 Activity、Fragment上。久而久之, Activity、Fragment的程式碼會越來越多,越來越肥,變得難以維護。
因此,才會有MVVM之類的新架構出現。目的是為了解決View和Controller高度耦合的問題。
MVVM
MVVM全名是Model-View-ViewModel的簡稱,以下是他各層所代表的職責:
- Model:資料層,即Data Model。用來取得或是儲存數據。
- View:程式的畫面,這裡指的是Activity、Fragment、佈局文件等。用來顯示應用程式的介面、負責使用者互動的部分。
- ViewModel:View和Model的中介層。負責處理業務邏輯。
以下是MVVM架構的示意圖:
和MVC不同的是,在MVVM架構中,Activity、Fragment等元件被歸類到了View層,因此Activity、Fragment只需處理畫面與使用者交互就好。
至於業務邏輯,則經由ViewModel處理。而Model則依舊負責處理資料存取。
以下實際用讀取手機中音樂檔,並透過RecyclerView呈現做為示範:
首先是MVC架構的實作方式:
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/btnLoad"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="讀取清單"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnLoad" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.kt
class MainActivity : AppCompatActivity() {
private var songList = ArrayList<Song>()
private val songAdapter = SongAdapter(songList)
private val mContext: Context by lazy { this }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnLoad.setOnClickListener {
// READ_EXTERNAL_STORAGE 屬危險權限,因此需動態申請
checkAndRequirePermission()
}
list.apply {
adapter = songAdapter
setHasFixedSize(true)
layoutManager = LinearLayoutManager(mContext, RecyclerView.VERTICAL, false)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
var isDenied = false
// 處理 mRequestCode 回傳的權限狀態
if (requestCode == mRequestCode) {
for (position in grantResults.indices) {
if (grantResults[position] == -1) {
isDenied = true
}
}
}
// 在權限都是被允許的狀態下,讀取歌曲清單
if (!isDenied) {
getSongList()
}
}
/**
* 檢查並要求權限
*/
private fun checkAndRequirePermission() {
val permission = checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
requestPermissions(
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE
),
mRequestCode
)
} else {
getSongList()
}
}
/**
* 取得歌曲清單
*/
private fun getSongList() {
songList.clear()
//retrieve song info
val musicResolver = mContext.contentResolver
val musicUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
val selection = MediaStore.Audio.Media.IS_MUSIC
val sortOrder = MediaStore.Audio.Media.DISPLAY_NAME
// 經由ContentProvider來取得外部儲存媒體上的音樂檔案的情報
val musicCursor = musicResolver.query(musicUri, null, selection, null, sortOrder)
if (musicCursor != null && musicCursor.moveToFirst()) {
//get columns
val idColumn = musicCursor.getColumnIndex(MediaStore.Audio.Media._ID)
val titleColumn = musicCursor.getColumnIndex(MediaStore.Audio.Media.TITLE)
val artistColumn = musicCursor.getColumnIndex(MediaStore.Audio.Media.ARTIST)
val totalTimeColumn = musicCursor.getColumnIndex(MediaStore.Audio.Media.DURATION)
//add songs to list
do {
val thisId = musicCursor.getLong(idColumn)
val thisTitle = musicCursor.getString(titleColumn)
val thisArtist = musicCursor.getString(artistColumn)
val thisTime = musicCursor.getInt(totalTimeColumn)
val song = Song(thisId, thisTitle, thisArtist, thisTime)
songList.add(song)
} while (musicCursor.moveToNext())
}
musicCursor?.close()
songAdapter.notifyDataSetChanged()
Log.i(TAG, "$songList")
}
companion object {
private const val mRequestCode = 0x100
private val TAG = MainActivity::class.java.simpleName
}
}
item_song.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:text="TextView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textArtist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:text="TextView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textTitle" />
<TextView
android:id="@+id/textTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="TextView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
SongAdapter.kt
class SongAdapter(data: ArrayList<Song>) : RecyclerView.Adapter<SongAdapter.ViewHolder>() {
private var mData: ArrayList<Song> = data
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val context = parent.context
val view = LayoutInflater.from(context).inflate(R.layout.item_song, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val song = mData[position]
holder.bindView(song)
}
override fun getItemCount(): Int = mData.size
inner class ViewHolder(view: View): RecyclerView.ViewHolder(view) {
private val textTitle = view.findViewById<TextView>(R.id.textTitle)
private val textArtist = view.findViewById<TextView>(R.id.textArtist)
private val textTime = view.findViewById<TextView>(R.id.textTime)
fun bindView(song: Song) {
textTitle.text = song.title
textArtist.text = song.artist
textTime.text = song.timeFormattedString
}
}
}
Song.kt
data class Song(
var id: Long,
var title: String,
var artist: String,
var time: Int
) {
val timeFormattedString: String
get() {
val minute = time / 1000 / 60
val sec = time / 1000 % 60
return "%02d:%02d".format(minute, sec) // 回傳經過格式化的時間字串,ex: 00:00
}
}
再來是MVVM的版本(由於layout.xml文件、data class幾乎一樣,因此不重複貼上原始碼):
MainActivity.kt
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
private var songAdapter = SongAdapter(arrayListOf())
private val mContext: Context by lazy { this }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewModel.songList.observe(this, {
songAdapter.updateData(it)
Log.i(TAG, "Songs: $it")
})
btnLoad.setOnClickListener {
// READ_EXTERNAL_STORAGE 屬危險權限,因此需動態申請
checkAndRequirePermission()
}
list.apply {
adapter = songAdapter
setHasFixedSize(true)
layoutManager = LinearLayoutManager(mContext, RecyclerView.VERTICAL, false)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
var isDenied = false
// 處理 mRequestCode 回傳的權限狀態
if (requestCode == mRequestCode) {
for (position in grantResults.indices) {
if (grantResults[position] == -1) {
isDenied = true
}
}
}
// 在權限都是被允許的狀態下,讀取歌曲清單
if (!isDenied) {
viewModel.getSongList()
}
}
/**
* 檢查並要求權限
*/
private fun checkAndRequirePermission() {
val permission = checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
requestPermissions(
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE
),
mRequestCode
)
} else {
viewModel.getSongList()
}
}
companion object {
private const val mRequestCode = 0x100
private val TAG = MainActivity::class.java.simpleName
}
}
SongAdapter.kt
class SongAdapter(data: ArrayList<Song>) : RecyclerView.Adapter<SongAdapter.ViewHolder>() {
private var mData: ArrayList<Song> = data
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val context = parent.context
val view = LayoutInflater.from(context).inflate(R.layout.item_song, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val song = mData[position]
holder.bindView(song)
}
override fun getItemCount(): Int = mData.size
fun updateData(newData: ArrayList<Song>) {
val diffCallback = SongDiffCallback(this.mData, newData)
val diffResult = DiffUtil.calculateDiff(diffCallback)
this.mData.clear()
this.mData.addAll(newData)
diffResult.dispatchUpdatesTo(this)
}
inner class ViewHolder(view: View): RecyclerView.ViewHolder(view) {
private val textTitle = view.findViewById<TextView>(R.id.textTitle)
private val textArtist = view.findViewById<TextView>(R.id.textArtist)
private val textTime = view.findViewById<TextView>(R.id.textTime)
fun bindView(song: Song) {
textTitle.text = song.title
textArtist.text = song.artist
textTime.text = song.timeFormattedString
}
}
inner class SongDiffCallback(oldSongList: List<Song>, newSongList: List<Song>) : DiffUtil.Callback() {
private val mOldSongList: List<Song> = oldSongList
private val mNewSongList: List<Song> = newSongList
override fun getOldListSize(): Int {
return mOldSongList.size
}
override fun getNewListSize(): Int {
return mNewSongList.size
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return mOldSongList[oldItemPosition].id == mNewSongList[newItemPosition].id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldSong: Song = mOldSongList[oldItemPosition]
val newSong: Song = mNewSongList[newItemPosition]
return oldSong.title == newSong.title
}
}
}
SongRepository.kt
object SongRepository {
private val songList = ArrayList<Song>()
fun getSongs(): ArrayList<Song> {
songList.clear()
//retrieve song info
val musicResolver = MyApp.getAppContext().contentResolver
val musicUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
val selection = MediaStore.Audio.Media.IS_MUSIC
val sortOrder = MediaStore.Audio.Media.DISPLAY_NAME
// 經由ContentProvider來取得外部儲存媒體上的音樂檔案的情報
val musicCursor = musicResolver.query(musicUri, null, selection, null, sortOrder)
if (musicCursor != null && musicCursor.moveToFirst()) {
//get columns
val idColumn = musicCursor.getColumnIndex(MediaStore.Audio.Media._ID)
val titleColumn = musicCursor.getColumnIndex(MediaStore.Audio.Media.TITLE)
val artistColumn = musicCursor.getColumnIndex(MediaStore.Audio.Media.ARTIST)
val totalTimeColumn = musicCursor.getColumnIndex(MediaStore.Audio.Media.DURATION)
//add songs to list
do {
val thisId = musicCursor.getLong(idColumn)
val thisTitle = musicCursor.getString(titleColumn)
val thisArtist = musicCursor.getString(artistColumn)
val thisTime = musicCursor.getInt(totalTimeColumn)
val song = Song(thisId, thisTitle, thisArtist, thisTime)
songList.add(song)
} while (musicCursor.moveToNext())
}
musicCursor?.close()
return songList
}
}
MainViewModel.kt
class MainViewModel: ViewModel() {
val songList: MutableLiveData<ArrayList<Song>> by lazy {
MutableLiveData<ArrayList<Song>>()
}
fun getSongList() {
val data = SongRepository.getSongs()
songList.postValue(data)
}
}
透過MVC、MVVM這兩個架構的實作結果,我們可以看到,Activity的功能變單純了。
原本Activity要處理畫面及使用者交互、還要負責動態取得READ_EXTERNAL_STORAGE權限、最後還要取得手機中的歌曲資料、最後顯示出來。
現在只需處理畫面和使用者交互、動態取得READ_EXTERNAL_STORAGE權限就好,剩下的動作則交給ViewModel完成。Activity的工作頓時變得單純許多。
最後附上參考來源: