Jetpack Compose 實現一個圖片選擇框架功能
知乎的 Matisse 應該蠻多 Android 開發者有瞭解過或者是曾經使用過,這是知乎在 2017 年開源的一個 Android 端圖片選擇框架,其顏值在現在看來也還是挺不錯的
可惜近幾年知乎官方已經不再對 Matisse 進行維護更新瞭,上一次提交記錄還停留在 2019 年,累積瞭 400 個 issues 一直沒人解答,很多高版本系統的兼容性問題和內部 bug 也一直得不到解決。我反編譯瞭知乎的 App,發現其內部還保留著 Matisse 的相關代碼,所以知乎應該不是完全廢棄瞭 Matisse,而隻是不再開源瞭
我公司的項目也使用到瞭 Matisse,隨著 Android 系統的更新,時不時地就會有用戶來反饋問題,無奈我也隻能 fork 瞭源碼自己來維護。一直這麼小修小補終究不太合適,而且如果不進行完全重寫的話,Matisse 的一些交互體驗問題也沒法得到徹底解決,而這些問題在知乎目前的官方 App 上也一樣存在,以修改個人頭像時打開的圖片選擇頁面為例:
我發現的問題有三個:
- 知乎的用戶頭像不支持 Gif 格式,當用戶點擊 Gif 圖片時會提示 “不支持的文件類型”。按我的想法,既然不支持 Gif 格式,那麼一開始展示的時候就應該過濾掉才對,而知乎目前的篩選邏輯應該就是來源自 Matisse ,因為 Matisse 也不支持 隻展示靜態圖,但又可以 隻展示 Gif,這篩選邏輯我覺得十分奇怪
- 當取消勾選靜態圖時,可以看到 Gif 圖片會很明顯地閃爍瞭一下,此問題在 Matisse 中也存在。而如果從知乎的編輯器進入圖片選擇頁面的話,就不單單是 Gif 圖片會閃爍瞭,而是整個頁面都會閃爍一下…
- 當點擊下拉菜單時,可以看到 Pictures 目錄中有三張圖片,但打開目錄又發現是空的。這是由於知乎沒有過濾掉一些臟數據導致的,後面會講到具體原因
由於以上問題,也讓我有瞭徹底放棄 Matisse,自己來實現一個新的圖片選擇框架的打算,也實現得差不多瞭,最終的效果如下所示
除瞭支持 Matisse 有的基本功能外,此框架的 特點 / 優勢 還有:
- 完全用 Kotlin 實現,拒絕 Java
- UI 層完全用 Jetpack Compose 實現,拒絕原生 View 體系
- 支持更加精細地自定義主題,默認提供瞭 日間 和 夜間 兩種主題
- 支持精準篩選圖片類型,隻會顯示想要的圖片類型
- 同時支持 FileProvider 和 MediaStore 兩種拍照策略
- 獲取到的圖片信息更加豐富,一共包含 uri、displayName、mimeType、width、height、orientation、size、path、bucketId、bucketDisplayName 等十個屬性值
- 已適配到 Android 12 系統,解決瞭幾個系統兼容性問題,下文會提到
此框架也有一些劣勢:
- 預覽圖片時不支持手勢縮放。一開始我有嘗試用 Jetpack Compose 來實現圖片手勢縮放,但效果不太理想,我又不想引入 View 體系中的三方庫,所以此版本暫不支持圖片手勢縮放
- 框架內部采用的圖片加載庫是 Coil,且不支持替換。由於目前支持 Jetpack Compose 的圖片加載庫基本隻能選擇 Coil 瞭,因此沒有提供替換圖片加載庫的入口
- 圖片列表的滑動性能要低於原生的 RecyclerView,debug 版本尤為明顯。此問題目前無解,隻能等 Google 官方後續的優化瞭
代碼我也開源到瞭 Github,懶得想名字,再加上一開始的設計思路也來自於 Matisse,因此就取瞭一樣的名字,也叫 Matisse。下文如果沒有特別說明,Matisse 指的就是此 Jetpack Compose 版本的圖片選擇框架瞭
用 Jetpack Compose 來實現 UI 相比原生的 View 體系實在要簡單很多,在這一塊除瞭滑動性能之外我也沒遇到其它問題。因此,本文的內容和 Jetpack Compose 無關,主要是講 Matisse 的一些實現細節和遇到的系統兼容性問題
獲取圖片
實現一個圖片選擇框架的第一步自然就是要獲取到相冊內的所有圖片瞭,因此需要申請 READ_EXTERNAL_STORAGE 權限,此外還需要依賴系統的 MediaStore API 來讀取所有圖片
MediaStore 相當於一個文件系統數據庫,記錄瞭當前設備中所有文件的索引,我們可以通過它來快速查找設備中特定類型的文件。Matisse 使用的是 MediaStore.Image
,在操作上就類似於查詢數據庫,通過聲明需要的數據庫字段 projection 和排序規則 sortOrder,得到相應的數據庫遊標 cursor,通過 cursor 遍歷查詢出每一個字段值
val projection = arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.ORIENTATION, MediaStore.Images.Media.DATA, MediaStore.Images.Media.BUCKET_ID, MediaStore.Images.Media.BUCKET_DISPLAY_NAME, ) val sortOrder = "${MediaStore.Images.Media.DATE_MODIFIED} DESC" val mediaResourcesList = mutableListOf<MediaResources>() val mediaCursor = context.contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, selection, selectionArgs, sortOrder, ) ?: return@withContext null mediaCursor.use { cursor -> while (cursor.moveToNext()) { val id = cursor.getLong(MediaStore.Images.Media._ID) val displayName = cursor.getString(MediaStore.Images.Media.DISPLAY_NAME) val mimeType = cursor.getString(MediaStore.Images.Media.MIME_TYPE) val width = cursor.getInt(MediaStore.Images.Media.WIDTH) val height = cursor.getInt(MediaStore.Images.Media.HEIGHT) val size = cursor.getLong(MediaStore.Images.Media.SIZE) val orientation = cursor.getInt(MediaStore.Images.Media.ORIENTATION) val data = cursor.getString(MediaStore.Images.Media.DATA) val bucketId = cursor.getString(MediaStore.Images.Media.BUCKET_ID) val bucketDisplayName = cursor.getString(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) val contentUri = ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ) val mediaResources = MediaResources( uri = contentUri, displayName = displayName, mimeType = mimeType, width = width, height = height, orientation = orientation, path = data, size = size, bucketId = bucketId, bucketDisplayName = bucketDisplayName, ) mediaResourcesList.add(mediaResources) } return@withContext mediaResourcesList }
每一張圖片都存放於特定的相冊文件夾內,因此可以通過 bucketId 來對每一張圖片進行歸類,從而得到 Matisse 中的下拉菜單
suspend fun groupByBucket(resources: List<MediaResources>): List<MediaBucket> { return withContext(context = Dispatchers.IO) { val resourcesMap = linkedMapOf<String, MutableList<MediaResources>>() resources.forEach { res -> val bucketId = res.bucketId val list = resourcesMap[bucketId] if (list == null) { resourcesMap[bucketId] = mutableListOf(res) } else { list.add(res) } } val allMediaBucketResource = mutableListOf<MediaBucket>() resourcesMap.forEach { val resourcesList = it.value if (resourcesList.isNotEmpty()) { val bucketId = it.key val bucketDisplayName = resourcesList[0].bucketDisplayName allMediaBucketResource.add( MediaBucket( bucketId = bucketId, bucketDisplayName = bucketDisplayName, bucketDisplayIcon = resourcesList[0].uri, resources = resourcesList, displayResources = resourcesList ) ) } } return@withContext allMediaBucketResource } }
拍照策略
一般的應用對於拍照功能不會有太多的自定義需求,因此大多是通過直接調起系統相機來實現拍照,優點是實現簡單,且不用申請 CAMERA 權限
實現代碼大致如下所示,最終圖片就會保存在 imageUri 指向的文件中
class MatisseActivity : ComponentActivity() { private var tempImageUri: Uri? = null private fun takePicture(imageUri: Uri) { tempImageUri = imageUri val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri) startActivityForResult(intent, 1) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == 1 && resultCode == Activity.RESULT_OK) { val mTempImageUri = tempImageUri if (mTempImageUri != null) { //TODO } } } }
以上代碼屬於通用流程,當判斷到完成拍照後,將以上的 imageUri 返回即可
但生成 imageUri 卻有著很多學問:不同的生成規則對應著不同的權限,甚至同種方式在不同系統版本上對權限的要求也不一樣,對用戶的感知也不一樣。此外,如果用戶在相機頁面取消拍照的話,此時 imageUri 指向的圖片文件就沒有用瞭,我們還需要主動刪除該文件
Matisse 通過 CaptureStrategy 接口來抽象以上邏輯
/** * 拍照策略 */ interface CaptureStrategy { /** * 是否啟用拍照功能 */ fun isEnabled(): Boolean /** * 是否需要申請讀取存儲卡的權限 */ fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean /** * 獲取用於存儲拍照結果的 Uri */ suspend fun createImageUri(context: Context): Uri? /** * 獲取拍照結果 */ suspend fun loadResources(context: Context, imageUri: Uri): MediaResources? /** * 當用戶取消拍照時調用 */ suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) /** * 生成圖片文件名 */ fun createImageName(): String { return UUID.randomUUID().toString() + ".jpg" } }
Matisse 實現瞭三種拍照策略供開發者選擇:
- NothingCaptureStrategy
- FileProviderCaptureStrategy
- MediaStoreCaptureStrategy
NothingCaptureStrategy
NothingCaptureStrategy 代表的是不開啟拍照功能,也是 Matisse 默認的拍照策略
/** * 什麼也不做,即不開啟拍照功能 */ object NothingCaptureStrategy : CaptureStrategy { override fun isEnabled(): Boolean { return false } override fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean { return false } override suspend fun createImageUri(context: Context): Uri? { return null } override suspend fun loadResources(context: Context, imageUri: Uri): MediaResources? { return null } override suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) { } }
FileProviderCaptureStrategy
顧名思義,此策略通過 FileProvider 來生成所需要的 imageUri
從 Android 7.0 開始,系統禁止應用通過 file://URI
來訪問其他應用的私有目錄文件,要在應用間共享私有文件,必須通過 content://URI
並授予 URI 臨時訪問權限來實現,否則將直接拋出異常。而將 File 轉換為 content://URI
的操作就需要依靠 FileProvider 來實現瞭。Matisse 傳遞給系統相機的 imageUri 也需要滿足此規則
FileProviderCaptureStrategy 采用的策略就是:
- 在 ExternalFilesDir 的 Pictures 目錄中創建一個圖片臨時文件用於存儲拍照結果,通過 FileProvider 得到該文件對應的
content://URI
,從而得到待寫入的 imageUri - 假如用戶最終取消拍照,則直接刪除創建的臨時文件
- 假如用戶最終完成拍照,則通過 BitmapFactory 獲取圖片的詳細信息
- 由於圖片是保存在應用自身的私有目錄中,因此不需要申請任何權限,也正因為是私有目錄,所以圖片不會出現在系統相冊中
/** * 通過 FileProvider 來生成拍照所需要的 ImageUri * 無需申請權限 * 所拍的照片不會保存在系統相冊裡 * 外部必須配置 FileProvider,並在此處傳入 authority */ class FileProviderCaptureStrategy(private val authority: String) : CaptureStrategy { private val uriFileMap = mutableMapOf<Uri, File>() override fun isEnabled(): Boolean { return true } override fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean { return false } override suspend fun createImageUri(context: Context): Uri? { return withContext(context = Dispatchers.IO) { return@withContext try { val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) val tempFile = File.createTempFile( createImageName(), "", storageDir ) val uri = FileProvider.getUriForFile( context, authority, tempFile ) uriFileMap[uri] = tempFile return@withContext uri } catch (e: Throwable) { e.printStackTrace() null } } } override suspend fun loadResources(context: Context, imageUri: Uri): MediaResources { return withContext(context = Dispatchers.IO) { val imageFile = uriFileMap[imageUri]!! uriFileMap.remove(imageUri) val imageFilePath = imageFile.absolutePath val option = BitmapFactory.Options() option.inJustDecodeBounds = true BitmapFactory.decodeFile(imageFilePath, option) return@withContext MediaResources( uri = imageUri, displayName = imageFile.name, mimeType = option.outMimeType ?: "", width = max(option.outWidth, 0), height = max(option.outHeight, 0), orientation = 0, size = imageFile.length(), path = imageFile.absolutePath, bucketId = "", bucketDisplayName = "" ) } } override suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) { withContext(context = Dispatchers.IO) { val imageFile = uriFileMap[imageUri]!! uriFileMap.remove(imageUri) if (imageFile.exists()) { imageFile.delete() } } } }
外部需要在自身項目中聲明 FileProvider,authorities 視自身情況而定,通過 authorities 來實例化 FileProviderCaptureStrategy
<provider android:name="androidx.core.content.FileProvider" android:authorities="github.leavesczy.matisse.samples.FileProvider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>
file_paths.xml
中需要配置 external-files-path
路徑的 Pictures 文件夾,name 可以隨意命名
<?xml version="1.0" encoding="utf-8"?> <paths> <external-files-path name="Capture" path="Pictures" /> </paths>
MediaStoreCaptureStrategy
顧名思義,此策略通過 MediaStore 來生成所需要的 imageUri
在 Android 10 系統之前,應用需要獲取到 WRITE_EXTERNAL_STORAGE 權限後才可以向共享存儲空間中寫入文件。從 Android 10 開始,應用通過 MediaStore 向共享存儲空間中寫入文件無需任何權限,且對於應用自身創建的文件,無需 READ_EXTERNAL_STORAGE 權限就可以直接訪問和刪除
MediaStoreCaptureStrategy 采用的策略就是:
- 在大於等於 10 的系統版本中,不申請 WRITE_EXTERNAL_STORAGE 權限,其它系統版本則進行申請
- 通過 MediaStore 向系統預創建一張圖片,從而得到待寫入的 imageUri
- 假如用戶最終取消拍照,則通過 MediaStore 刪除 imageUri 指向的臟數據
- 假如用戶最終完成拍照,則通過 MediaStore 去查詢 imageUri 對應圖片的詳細信息
- 由於圖片一開始就保存在 MediaStore 中,因此圖片會顯示在系統相冊中
/** * 通過 MediaStore 來生成拍照所需要的 ImageUri * 根據系統版本決定是否需要申請 WRITE_EXTERNAL_STORAGE 權限 * 所拍的照片會保存在系統相冊裡 */ class MediaStoreCaptureStrategy : CaptureStrategy { override fun isEnabled(): Boolean { return true } override fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return false } return ActivityCompat.checkSelfPermission( context, Manifest.permission.WRITE_EXTERNAL_STORAGE ) == PackageManager.PERMISSION_DENIED } override suspend fun createImageUri(context: Context): Uri? { return MediaProvider.createImage(context = context, fileName = createImageName()) } override suspend fun loadResources(context: Context, imageUri: Uri): MediaResources? { return MediaProvider.loadResources( context = context, uri = imageUri ) } override suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) { MediaProvider.deleteImage(context = context, imageUri = imageUri) } }
總結
所以說,除瞭 NothingCaptureStrategy 代表不開啟拍照功能外,其他兩種策略所需要的權限和圖片存儲的位置都不一樣,對用戶的感知也不一樣
拍照策略 | 所需權限 | 配置項 | 對用戶是否可見 |
---|---|---|---|
NothingCaptureStrategy | |||
FileProviderCaptureStrategy | 無 | 外部需要配置 FileProvider | 否,圖片存儲在應用私有目錄內,對用戶不可見 |
MediaStoreCaptureStrategy | Android 10 之前需要 WRITE_EXTERNAL_STORAGE 權限,Android 10 開始不需要權限 | 無 | 是,圖片存儲在系統相冊內,對用戶可見 |
開發者根據自己的實際情況來決定選擇哪一種策略:
- 如果應用本身就需要申請 WRITE_EXTERNAL_STORAGE 權限的話,選 MediaStoreCaptureStrategy,拍照後的圖片保存在系統相冊中也比較符合用戶的認知
- 如果應用本身就不需要申請 WRITE_EXTERNAL_STORAGE 權限的話,選 FileProviderCaptureStrategy,為瞭相冊問題而多申請一個敏感權限得不償失
拍照權限
Android 系統的 CAMERA 權限用於自定義實現相機功能的業務場景,也即如果使用到瞭 Camera API 的話,應用就必須聲明和申請 CAMERA 權限
而調起系統相機進行拍照不屬於自定義實現,因此該操作本身是不要求 CAMERA 權限的,但是否真的不需要申請權限要根據實際情況而定
Android 系統對於 CAMERA 權限有著比較奇怪的要求:
- 應用如果沒有聲明 CAMERA 權限,此時調起系統相機不需要申請任何權限
- 應用如果有聲明 CAMERA 權限,就必須等到用戶同意瞭 CAMERA 權限後才能調起系統相機,否則將直接拋出 SecurityException
因此,雖然 Matisse 本身是通過調起系統相機來實現拍照的,但如果引用方聲明瞭 CAMERA 權限的話,將連鎖導致 Matisse 也必須申請 CAMERA 權限
為瞭解決這個問題,Matisse 通過檢查應用的 Manifest 文件中是否包含 CAMERA 權限來決定是否需要進行申請,避免由於意外而奔潰
private fun requestCameraPermissionIfNeed() { if (PermissionUtils.containsPermission( context = this, permission = Manifest.permission.CAMERA ) && !PermissionUtils.checkSelfPermission( context = this, permission = Manifest.permission.CAMERA ) ) { requestCameraPermission.launch(Manifest.permission.CAMERA) } else { takePicture() } } internal object PermissionUtils { /** * 檢查是否已授權指定權限 */ fun checkSelfPermission(context: Context, permission: String): Boolean { return ActivityCompat.checkSelfPermission( context, permission ) == PackageManager.PERMISSION_GRANTED } /** * 檢查應用的 Manifest 文件是否聲明瞭指定權限 */ fun containsPermission(context: Context, permission: String): Boolean { val packageManager: PackageManager = context.packageManager try { val packageInfo = packageManager.getPackageInfo( context.packageName, PackageManager.GET_PERMISSIONS ) val permissions = packageInfo.requestedPermissions if (!permissions.isNullOrEmpty()) { return permissions.contains(permission) } } catch (e: Throwable) { e.printStackTrace() } return false } }
取消拍照導致的臟數據
在文章開頭給出來的知乎官方 App 示例中可以看到,Pictures 目錄明明顯示有三張圖片,但點擊進去又發現目錄是空的。這是由於 MediaStore 中存在臟數據導致的
當應用通過 MediaStoreCaptureStrategy 來啟動相機時,已經先向 MediaStore 插入一條圖片數據瞭,但如果用戶此時又取消瞭拍照,就會導致 MediaStore 中存在一條臟數據:該數據有 id、uri、path、displayName 等信息,但對應的圖片文件實際上並不存在。知乎 App 應該是一開始在歸類圖片目錄的時候沒有檢查圖片是否真的存在,等到要加載圖片的時候才發現圖片不可用
雖然 MediaStoreCaptureStrategy 會主動刪除自己生成的臟數據,但我們沒法確保其它應用就不會向 MediaStore 插入臟數據。因此,Matisse 會在遍歷查詢所有圖片的過程中,同時判斷該圖片指向的文件是否真的存在,有的話才進行展示
mediaCursor.use { cursor -> while (cursor.moveToNext()) { val data = cursor.getString(MediaStore.Images.Media.DATA) if (data.isBlank() || !File(data).exists()) { continue } //TODO } }
resolveActivity API 的兼容性
當我們要隱式啟動一個 Activity 的時候,為瞭避免由於目標 Activity 不存在而導致應用崩潰,我們就需要在 startActivity 前先判斷該隱式啟動是否有接收者,有的話才去調用 startActivity
Matisse 在啟動系統相機的時候也是如此,會先通過 resolveActivity
方法查詢系統中是否有應用可以處理拍照請求,有的話才去啟動相機,避免由於設備沒有攝像頭而導致應用崩潰
private fun takePicture() { lifecycleScope.launch { val imageUri = captureStrategy.createImageUri(context = this@MatisseActivity) tempImageUri = imageUri if (imageUri != null) { val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) if (captureIntent.resolveActivity(packageManager) != null) { takePictureLauncher.launch(imageUri) } } } }
但 resolveActivity
方法在 Android 11 和更高的系統上也有著一個兼容性問題:軟件包可見性過濾
如果應用的目標平臺是 Android 11 或更高版本,那麼當應用通過 queryIntentActivities()、getPackageInfo()、getInstalledApplications()
等方法查詢設備上已安裝的其它應用相關信息時,系統會默認對返回結果進行過濾。也就是說,通過這些方法查詢到的應用信息會少於設備上真實安裝的應用數。resolveActivity
方法也受到此影響,經測試,在 Android 11 和 Android 12 的模擬器上,resolveActivity 方法均會返回 null,但在一臺 Android 12 的真機上返回值則不為 null,因為不同設備會根據自己的實際情況來決定哪些實現 Android 核心功能的系統服務對所有應用均可見
Matisse 的解決方案是:在 Manifest 文件中通過 queries
主動聲明 IMAGE_CAPTURE,從而提高對此 action 的可見性
<queries> <intent> <action android:name="android.media.action.IMAGE_CAPTURE" /> </intent> </queries>
File API 的兼容性
嚴格來說,File API 的兼容性並不屬於 Matisse 遇到的問題,而是外部使用者會遇到的問題
從 Android 10 開始,系統推出瞭分區存儲的特性,限制瞭應用讀寫共享文件的方式。當應用開啟分區存儲特性後,對共享文件的讀寫需要通過 MediaStore 來實現,而不能使用以前常用的 File API,否則將直接拋出異常:FileNotFoundException open failed: EACCES (Permission denied)
例如,像 Glide、Coil 等圖片框架均支持通過 ByteArray 來加載圖片,對於開啟瞭分區存儲特性的應用,在 Android 10 系統之前,以下方式是完全可用的,但在 Android 10 系統上就會直接崩潰
val filePath: String = xxx imageView.load(File(filePath).readBytes())
而到瞭 Android 11 後,Google 可能覺得這種限制對於應用來說過於嚴格,因此又取消瞭限制,允許應用繼續通過 File API 來讀寫共享文件,系統會自動將 File API 重定向為 MediaStore API =_=
因此,雖然 Matisse 的返回值中包含瞭圖片的絕對路徑 path,但如果外部開啟瞭分區存儲特性的話,在 Android 10 設備上是不能直接通過 File API 來讀寫共享文件的,在其它系統版本上則可以繼續使用
Github
以上就是 Matisse 的一些實現細節和遇到的系統兼容性問題,更多實現細節請看 Github:Matisse
Matisse 同時也發佈到瞭 Jitpack,方便開發者直接遠程依賴使用:
allprojects { repositories { maven { url "https://jitpack.io" } } } dependencies { implementation 'com.github.leavesCZY:Matisse:0.0.1' }
到此這篇關於Jetpack Compose 實現一個圖片選擇框架的文章就介紹到這瞭,更多相關Jetpack Compose圖片選擇框架內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!