第16章 使用 intent 拍照
方案分析
文件存儲
相機拍攝的照片動輒幾MB大小,保存在SQLite數據庫中肯定不現實。顯然,它們需要在設備文件系統的某個地方保存。
設備上就有這麼一個地方:私有存儲空間,像照片這樣的文件也可以這麼保存。
因爲要處理的照片都是私有文件,只有你自己的應用能讀寫,
如果其他應用要讀寫你的文件,雖然Context.MODE_WORLD_READABLE可以傳入openFileOutput(String, Int)函數,但這個flag已經廢棄了。即使強制使用,在新系統設備上也不是很可靠。以前,還可以通過公共外部存儲轉存,但出於安全考慮,這條路在新版本系統上也被堵住了。
ContentProvider
如果想共享文件給其他應用,或是接收其他應用的文件(比如相機應用拍攝的照片),可以通過ContentProvider把要共享的文件暴露出來。
ContentProvider允許你暴露內容URI給其他應用。這樣,這些應用就可以從內容URI下載或向其中寫入文件。當然,主動權在你手上,你可以控制讀或寫。
如果只想從其他應用接收一個文件,自己實現ContentProvider簡直是費力不討好的事。
Google早已想到這點,因此提供了一個名爲FileProvider的便利類。這個類能幫你搞定一切,而你只要做做參數配置就行了。
使用 FileProvider
聲明FileProvider爲ContentProvider,並給予一個指定的權限。在AndroidManifest.xml中添加一個FileProvider聲明
代碼清單: 添加FileProvider聲明(manifests/AndroidManifest.xml)
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.criminalintent">
<application .../>
<activity
.../>
...
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.example.criminalintent.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/files"/>
</provider>
</application>
</manifest>
其中:
- com.example.criminalintent.fileprovider:是授權字符串,後面會用到。
- exported="false": 處了自己和授權的人,其它任何人不允許使用你的 FileProvider
- android:grantUriPermissions="true":授權其它應用,允許它們向你指定位置的URI寫入文件
- resource="@xml/files":指定要暴露的文件:執行一個xml資源文件。
右鍵點擊 app/res 目錄,New -> Android resource file, 資源類型選擇xml,文件名輸入 files,確定創建文件,替換爲如下內容
代碼清單: res/xm/files.xml
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="crime_photos" path="."/>
</paths>
照片存放位置
代碼清單:Crime.kt
@Entity
data class Crime(...) {
val photoFileName get() = "IMG_$id.jpg"
}
代碼清單:CrimeRepository.kt
class CrimeRepository private constructor(context: Context) {
...
private var filesDir = context.applicationContext.filesDir
fun getPhotoFile(crime: Crime):File = File(filesDir, crime.photoFileName)
...
}
- filesDir:返回的目錄:/data/user/0/com.example.criminalintent/files
在 Device File Exploer 的位置見下圖:
直接在真機的文件管理中是無法查看的,真機的操作系統已經將其隱藏,只能在 Android Studio 的 Device File Exploer 查看。
- getPhotoFile返回圖片地址,比如:/data/user/0/com.example.criminalintent/files/IMG_454f516f-4812-42dc-a06a-479ceb5c359b.jpg
代碼清單:CrimeRepository.kt
class CrimeDetailViewModel : ViewModel() {
...
fun getPhotoFile(crime: Crime): File {
return crimeRepository.getPhotoFile(crime)
}
}
使用相機 intent
佈局文件,添加 拍照按鈕的ImageButton 和顯示照片的ImageView
代碼清單:res/layout/fragment_crime.xml
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/crime_photo"
android:layout_width="80dp"
android:layout_height="80dp"
android:scaleType="centerInside"
android:cropToPadding="true"
android:background="@android:color/darker_gray"/>
<ImageButton
android:id="@+id/crime_camera"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:src="@android:drawable/ic_menu_camera"/>
</LinearLayout>
<LinearLayout
...
</LinearLayout>
...
</LinearLayout>
代碼清單:CrimeFragment.kt
class CrimeFragment : Fragment()
...
private lateinit var photoFile: File
private lateinit var photoUri: Uri
private lateinit var photoButton: ImageButton
private lateinit var photoView: ImageView
// 啓動照相機 activity 的intent
private val captureImageIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
// 啓動照相機 activity 的啓動器
private lateinit var captureActivityResultLauncher: ActivityResultLauncher<Intent>
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
// 照相機 Activity啓動器
captureActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK
&& result.data != null) {
updatePhotoView()
//撤銷權限
requireActivity().revokeUriPermission(photoUri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
...
photoButton = view.findViewById(R.id.crime_camera) as ImageButton
photoView = view.findViewById(R.id.crime_photo) as ImageView
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
crimeDetailViewModel.crimeLiveData.observe(
viewLifecycleOwner,
Observer { crime ->
crime?.let {
this.crime = crime
// 照片文件:路徑 + 文件名
photoFile = crimeDetailViewModel.getPhotoFile(crime)
Log.d(TAG,"圖片文件:$photoFile")
// 把照片文件封裝成 Uri
photoUri = FileProvider.getUriForFile(
requireActivity(), // 當前的Activity
"com.example.criminalintent.fileprovider", // 授權字符串,與 AndroidManifest.xml 文件裏的一致
photoFile //照片文件
)
updateUI()
}
}
)
}
override fun onStart() {
...
photoButton.apply {
val packageManager: PackageManager = requireActivity().packageManager
// 檢查是否有相機應用
var resolvedActivity: ResolveInfo? =
packageManager.resolveActivity(captureImageIntent, PackageManager.MATCH_DEFAULT_ONLY)
if (resolvedActivity == null) {
isEnabled = false
}
setOnClickListener {
//設置照片存儲的Uri
captureImageIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
val cameraActivities: List<ResolveInfo> =
packageManager.queryIntentActivities(
captureImageIntent,
PackageManager.MATCH_DEFAULT_ONLY)
// 授予所有的照相機在photoUri指定的位置寫入文件的權限
for (cameraActivity in cameraActivities) {
requireActivity().grantUriPermission(
cameraActivity.activityInfo.packageName,
photoUri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
// 啓動照相機 activity
captureActivityResultLauncher.launch(captureImageIntent)
}
}
}
private fun updatePhotoView() {
if (photoFile.exists()) {
val bitmap = getScaledBitmap(photoFile.path, requireActivity() )
photoView.setImageBitmap(bitmap)
}else {
photoView.setImageBitmap(null)
}
}
private fun updateUI() {
...
updatePhotoView()
}
override fun onDetach() {
super.onDetach()
//撤銷權限
requireActivity().revokeUriPermission(photoUri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
}
-
Intent(MediaStore.ACTION_IMAGE_CAPTURE): 啓動照相機 activity 的intent,action名稱:MediaStore.ACTION_IMAGE_CAPTURE
-
captureActivityResultLauncher : 啓動照相機 activity 的啓動器
-
設置照片存儲的Uri:captureImageIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri):
設置照相機的拍照後照片的存儲位置Uri:photoUri,並使用授權字符進行標記 ,見如下代碼:// 把照片文件封裝成 Uri photoUri = FileProvider.getUriForFile( requireActivity(), // 當前的Activity "com.example.criminalintent.fileprovider", // 授權字符串,與 AndroidManifest.xml 文件裏的一致 photoFile //照片文件:路徑+文件名 )
-
Uri授權:授予所有的照相機在photoUri指定的位置寫入文件的權限:
requireActivity().grantUriPermission( cameraActivity.activityInfo.packageName, photoUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION )
-
寫入授權:Intent.FLAG_GRANT_WRITE_URI_PERMISSION :
-
啓動照相機 activity: captureActivityResultLauncher.launch(captureImageIntent)
-
相機存儲完照片,返回後調用 updatePhotoView() 更新 imageView。
-
並且用完記得在相機回調函數,和 onDetach()撤銷權限 Intent.FLAG_GRANT_WRITE_URI_PERMISSION
-
日誌:
- 圖片位置的日誌:
圖片文件:/data/user/0/com.example.criminalintent/files/IMG_454f516f-4812-42dc-a06a-479ceb5c359b.jpg
- 圖片原始寬高和轉換後的寬高:
2022-06-03 17:29:42.474 27366-27366/com.example.criminalintent D/PictureUtils: destWidth:1080, srcWidth:4000.0 2022-06-03 17:29:42.475 27366-27366/com.example.criminalintent D/PictureUtils: destHeight:2276, srcHeight:3000.0
- 圖片位置的日誌:
-
直接在真機的文件管理中是無法查看的,真機的操作系統已經將其隱藏,只能在 Android Studio 的 Device File Exploer 查看。
- 因爲每個 crime的圖片都是 IMG+crime.id 組成,每次拍照得到的文件名相同,保存會覆蓋掉舊的照片,故,每一個crime即使是拍照多次, 只有對應的一張圖片
縮放和顯示位圖
Bitmap是個簡單對象,它只存儲實際像素數據。也就是說,即使原始照片已壓縮過,但存入Bitmap對象時,文件並不會同樣壓縮。因此,一張1600萬像素24位的相機照片(存爲JPG格式大約5 MB),一旦載入Bitmap對象,就會立即膨脹至48 MB!
這個問題可以設法解決,但需要手動縮放位圖照片。具體做法是,首先確認文件到底有多大,然後考慮按照給定區域大小合理縮放文件。最後,重新讀取縮放後的文件,創建Bitmap對象
代碼清單:PictureUtils.kt
package com.example.criminalintent
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
private const val TAG = "PictureUtils"
fun getScaledBitmap(path: String, activity: Activity): Bitmap {
val metrics = activity.resources.displayMetrics
return getScaledBitmap(path, metrics.widthPixels, metrics.heightPixels)
}
/**
* 縮放位圖照片
* */
fun getScaledBitmap(path: String, destWidth: Int, destHeight: Int): Bitmap {
// 讀取目標文件
var options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(path, options)
val srcWidth = options.outWidth.toFloat()
val srcHeight = options.outHeight.toFloat()
Log.d(TAG, "destWidth:$destWidth, srcWidth:$srcWidth")
Log.d(TAG, "destHeight:$destHeight, srcHeight:$srcHeight")
/**計算縮放比例
* 1:縮略圖與原始圖標的水平像素一樣
* 2:表示水平像素比爲 1:2,即:縮略圖的像素是原始文件的 1/4
*/
var inSampleSize = 1
if (srcHeight > destHeight || srcWidth > destWidth) {
val heightScale = srcHeight / destHeight
val widthScale = srcWidth / destWidth
val sampleScale = if (heightScale > widthScale) {
heightScale
}else{
widthScale
}
inSampleSize = Math.round(sampleScale)
}
options = BitmapFactory.Options()
options.inSampleSize = inSampleSize
// 讀取和創建最終的BitMap
return BitmapFactory.decodeFile(path, options)
}
功能聲明
應用的拍照功能用起來不錯,但還有一件事情要做:告訴潛在用戶應用有拍照功能。
假如應用要用到諸如相機、NFC,或者任何其他的隨設備走的功能時,都應該讓Android系統知道。這樣,假如設備缺少這樣的功能,類似Google Play商店的安裝程序就會拒絕安裝應用。
爲了聲明應用要使用相機,在AndroidManifest.xml中加入
代碼清單:添加
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.criminalintent">
...
<uses-feature android:name="android.hardware.camera"
android:required="false"/>
</manifest>
注意,我們在代碼中使用了android:required屬性。默認情況下,聲明要使用某個設備功能後,應用就無法支持那些無此功能的設備了,但這不適用於CriminalIntent應用。這是因爲,resolveActivity(...)函數可以判斷設備是否支持拍照。如果不支持,就直接禁用拍照按鈕。
無論如何,這裏設置android:required屬性爲false,Android系統因此就知道,儘管不帶相機的設備會導致應用功能缺失,但應用仍然可以正常安裝和使用
真機運行
運行 CriminalIntent,