技術學習記錄

Android APP常見的架構

前言

從事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的工作頓時變得單純許多。

最後附上參考來源:

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *