Android 拍照及相簿選取圖片功能,已適配Android6.0、7.0、8.0

code小生發表於2018-07-05

更換頭像或者上傳圖片功能已基本是每個 APP 所具備的基礎功能了,但這對於開發者來說是一個很麻煩的事情,除機型之外,適配版本就至少要考慮這幾種情況(6.0以下版本、6.0的動態許可權、7.0的FileProvider、8.0的特殊情況)。

今天來個總結,方便自己也方便同行。


功能說明

本文的示例以下圖為準:
選擇圖片

介面只有一個 ImageView,點選 ImageView 彈出 DialogFragment,分別是拍照和相簿選擇圖片功能,其中都帶有系統的裁剪功能,將裁剪後的圖片顯示在 ImageView 上。如果不需要裁剪功能,只需要將程式碼中的裁剪方法註釋掉即可。

6.0 以下版本

1. 許可權

<uses-feature android:name="android.hardware.camera" />
    <!--相機許可權-->
    <uses-permission android:name="android.permission.CAMERA" />
    <!--寫入SD卡的許可權:如果你希望儲存相機拍照後的照片-->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <!--讀取SD卡的許可權:開啟相簿選取圖片所必須的許可權-->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

2. 拍照程式碼

/**
     * 開啟系統相機
     */
    private void openSysCamera() {
        Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(
                new File(Environment.getExternalStorageDirectory(), imgName)));
        startActivityForResult(cameraIntent, CAMERA_RESULT_CODE);
    }

其中 imgName 是拍照圖片的名字,一般以時間戳再加上自定義字串命名;CAMERA_RESULT_CODE 是自定義的一個常量,作為拍照的請求碼。

onActivityResult 處理拍照後的圖片

case CAMERA_RESULT_CODE:
                tempFile = new File(Environment.getExternalStorageDirectory(), imgName);
                cropPic(Uri.fromFile(tempFile));
                break;

將拍照後的圖片建立成一個 File 物件,用來裁剪,裁剪的功能程式碼是和相簿選取圖片通用的。

裁剪程式碼

/**
     * 裁剪圖片
     *
     * @param data
     */
    private void cropPic(Uri data) {
        if (data == null) {
            return;
        }
        Intent cropIntent = new Intent("com.android.camera.action.CROP");
        cropIntent.setDataAndType(data, "image/*");

        // 開啟裁剪:開啟的Intent所顯示的View可裁剪
        cropIntent.putExtra("crop", "true");
        // 裁剪寬高比
        cropIntent.putExtra("aspectX", 1);
        cropIntent.putExtra("aspectY", 1);
        // 裁剪輸出大小
        cropIntent.putExtra("outputX", 320);
        cropIntent.putExtra("outputY", 320);
        cropIntent.putExtra("scale", true);
        /**
         * return-data
         * 這個屬性決定我們在 onActivityResult 中接收到的是什麼資料,
         * 如果設定為true 那麼data將會返回一個bitmap
         * 如果設定為false,則會將圖片儲存到本地並將對應的uri返回,當然這個uri得有我們自己設定。
         * 系統裁剪完成後將會將裁剪完成的圖片儲存在我們所這設定這個uri地址上。我們只需要在裁剪完成後直接呼叫該uri來設定圖片,就可以了。
         */
        cropIntent.putExtra("return-data", true);
        // 當 return-data 為 false 的時候需要設定這句
//        cropIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
        // 圖片輸出格式
//        cropIntent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString());
        // 頭像識別 會啟動系統的拍照時人臉識別
//        cropIntent.putExtra("noFaceDetection", true);
        startActivityForResult(cropIntent, CROP_RESULT_CODE);
    }

其中的 CROP_RESULT_CODE 也是定義的靜態常量,同拍照時定義的常量相同的作用。

如果裁剪時將 return-data 設定為 false,那麼需要定義一個 Uri 來儲存裁剪後的圖片路徑。

 // 裁剪屬性 cropIntent.putExtra("return-data", false); 時,使用自定義接收圖片的Uri
    private static final String IMAGE_FILE_LOCATION = "file:///" + Environment.getExternalStorageDirectory().getPath() + "/temp.jpg";
    private Uri imageUri = Uri.parse(IMAGE_FILE_LOCATION);

圖片裁剪完成回撥

 case CROP_RESULT_CODE:
                // 裁剪時,這樣設定 cropIntent.putExtra("return-data", true); 處理方案如下
                if (data != null) {
                    Bundle bundle = data.getExtras();
                    if (bundle != null) {
                        Bitmap bitmap = bundle.getParcelable("data");
                        imageView.setImageBitmap(bitmap);
                        // 把裁剪後的圖片儲存至本地 返回路徑
                        String urlpath = FileUtilcll.saveFile(this, "crop.jpg", bitmap);
                        L.e("裁剪圖片地址->" + urlpath);
                    }
                }

                // 裁剪時,這樣設定 cropIntent.putExtra("return-data", false); 處理方案如下
//                try {
//                    ivHead.setImageBitmap(BitmapFactory.decodeStream(
// getActivity().getContentResolver().openInputStream(imageUri)));
//                } catch (FileNotFoundException e) {
//                    e.printStackTrace();
//                }
                break;

FileUtilcll 程式碼

/**
 * 圖片檔案操作
 */
public class FileUtilcll {

    /**
     * 將Bitmap 圖片儲存到本地路徑,並返回路徑
     *
     * @param fileName 檔名稱
     * @param bitmap   圖片
     * @param資源型別,參照 MultimediaContentType 列舉,根據此型別,儲存時可自動歸類
     */
    public static String saveFile(Context c, String fileName, Bitmap bitmap) {
        return saveFile(c, "", fileName, bitmap);
    }

    public static String saveFile(Context c, String filePath, String fileName, Bitmap bitmap) {
        byte[] bytes = bitmapToBytes(bitmap);
        return saveFile(c, filePath, fileName, bytes);
    }

    /**
     * Bitmap 轉 位元組陣列
     *
     * @param bm
     * @return
     */
    public static byte[] bitmapToBytes(Bitmap bm) {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        bm.compress(CompressFormat.JPEG, 100, baos);
        return baos.toByteArray();
    }

    public static String saveFile(Context c, String filePath, String fileName, byte[] bytes) {
        String fileFullName = "";
        FileOutputStream fos = null;
        String dateFolder = new SimpleDateFormat("yyyyMMddHHmmss", Locale.CHINA)
                .format(new Date());
        try {
            String suffix = "";
            if (filePath == null || filePath.trim().length() == 0) {
                filePath = Environment.getExternalStorageDirectory() + "/cxs/" + dateFolder + "/";
            }
            File file = new File(filePath);
            if (!file.exists()) {
                file.mkdirs();
            }
            File fullFile = new File(filePath, fileName + suffix);
            fileFullName = fullFile.getPath();
            fos = new FileOutputStream(new File(filePath, fileName + suffix));
            fos.write(bytes);
        } catch (Exception e) {
            fileFullName = "";
        } finally {
            if (fos != null) {
                try {
                    fos.close();
                } catch (IOException e) {
                    fileFullName = "";
                }
            }
        }
        return fileFullName;
    }

}

到此,拍照功能就完成了,附帶了裁剪。剩下的就是相簿選取照片,這個也不難,固定程式碼,其他功能和拍照時相同的。

 /**
     * 開啟系統相簿
     */
    private void openSysAlbum() {
        Intent albumIntent = new Intent(Intent.ACTION_PICK);
        albumIntent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*");
        startActivityForResult(albumIntent, ALBUM_RESULT_CODE);
    }

其中的 ALBUM_RESULT_CODE 同樣是定義的常量。用來在 onActivityResult 中處理返回結果。

case ALBUM_RESULT_CODE:
                // 相簿
                cropPic(data.getData());
                break;

和拍照公用裁剪程式碼,這裡直接呼叫即可,其他邏輯不變。


6.0動態許可權適配

除了上述功能保持不變之外,需要在程式碼中動態申請檔案讀寫許可權和相機許可權。這裡我將申請的時機寫在了剛進入頁面,而不是剛開啟 APP,也不是點選按鈕的時候。

邏輯

  • 如果使用者點選了拒絕,但沒有點選“不再詢問”,這個時候再次進入介面繼續彈框;
  • 如果使用者點選了拒絕,且選擇了“不再詢問”,那麼再次進入此介面將會彈框提示開啟 APP 的詳情介面,手動開啟對應許可權。

許可權申請程式碼

/**
     * 初始化相機相關許可權
     * 適配6.0+手機的執行時許可權
     */
    private void initPermission() {
        String[] permissions = new String[]{Manifest.permission.CAMERA,
                Manifest.permission.WRITE_EXTERNAL_STORAGE,
                Manifest.permission.READ_EXTERNAL_STORAGE};
        //檢查許可權
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
                != PackageManager.PERMISSION_GRANTED) {
            // 之前拒絕了許可權,但沒有點選 不再詢問 這個時候讓它繼續請求許可權
            if (ActivityCompat.shouldShowRequestPermissionRationale(this,
                    Manifest.permission.CAMERA)) {
                Toast.makeText(this, "使用者曾拒絕開啟相機許可權", Toast.LENGTH_SHORT).show();
                ActivityCompat.requestPermissions(this, permissions, REQUEST_PERMISSIONS);
            } else {
                //註冊相機許可權
                ActivityCompat.requestPermissions(this, permissions, REQUEST_PERMISSIONS);
            }
        }
    }

這裡我將需要的許可權統一寫在了一個陣列裡面。其中的 REQUEST_PERMISSIONS 和上面拍照定義的常量功能相同。

許可權申請回撥

@Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
                                           @NonNull int[] grantResults) {
        switch (requestCode) {
            case REQUEST_PERMISSIONS:
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    //成功
                    Toast.makeText(this, "使用者授權相機許可權", Toast.LENGTH_SHORT).show();
                } else {
                    // 勾選了不再詢問
                    Toast.makeText(this, "使用者拒絕相機許可權", Toast.LENGTH_SHORT).show();
                    /**
                     * 跳轉到 APP 詳情的許可權設定頁
                     *
                     * 可根據自己的需求定製對話方塊,點選某個按鈕在執行下面的程式碼
                     */
                    Intent intent = Util.getAppDetailSettingIntent(PhotoFromSysActivity.this);
                    startActivity(intent);
                }
                break;
        }
    }

其中的 getAppDetailSettingIntent()方法程式碼如下:

/**
     * 獲取 APP 詳情頁面intent
     *
     * @return
     */
    public static Intent getAppDetailSettingIntent(Context context) {
        Intent localIntent = new Intent();
        localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        if (Build.VERSION.SDK_INT >= 9) {
            localIntent.setAction("android.settings.APPLICATION_DETAILS_SETTINGS");
            localIntent.setData(Uri.fromParts("package", context.getPackageName(), null));
        } else if (Build.VERSION.SDK_INT <= 8) {
            localIntent.setAction(Intent.ACTION_VIEW);
            localIntent.setClassName("com.android.settings", "com.android.settings.InstalledAppDetails");
            localIntent.putExtra("com.android.settings.ApplicationPkgName", context.getPackageName());
        }
        return localIntent;
    }

到這,許可權這個適配問題就解決了,接下來該是 Android 7.0 的適配了,這裡我們只需要修改拍照的功能即可,相簿是沒有問題的。


Android7.0 適配

Android 7.0 就是 File 路徑的變更,需要使用 FileProvider 來做,下面看拍照的程式碼。

拍照程式碼修改

/**
     * 開啟系統相機
     */
    private void openSysCamera() {
        Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
//        cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(
//                new File(Environment.getExternalStorageDirectory(), imgName)));
//        File file = new File(Environment.getExternalStorageDirectory(), imgName);
        try {
            file = createOriImageFile();
        } catch (IOException e) {
            e.printStackTrace();
        }

        if (file != null) {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
                imgUriOri = Uri.fromFile(file);
            } else {
                imgUriOri = FileProvider.getUriForFile(this, getPackageName() + ".provider", file);
            }
            cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, imgUriOri);
            startActivityForResult(cameraIntent, CAMERA_RESULT_CODE);
        }
    }

File 物件的建立和 拍照圖片的 Uri 物件建立方式更改。建立原影像儲存的程式碼如下:

/**
     * 建立原影像儲存的檔案
     *
     * @return
     * @throws IOException
     */
    private File createOriImageFile() throws IOException {
        String imgNameOri = "HomePic_" + new SimpleDateFormat(
                "yyyyMMdd_HHmmss").format(new Date());
        File pictureDirOri = new File(getExternalFilesDir(
                Environment.DIRECTORY_PICTURES).getAbsolutePath() + "/OriPicture");
        if (!pictureDirOri.exists()) {
            pictureDirOri.mkdirs();
        }
        File image = File.createTempFile(
                imgNameOri,         /* prefix */
                ".jpg",             /* suffix */
                pictureDirOri       /* directory */
        );
        imgPathOri = image.getAbsolutePath();
        return image;
    }

拍照回撥程式碼修改

 case CAMERA_RESULT_CODE:
//                tempFile = new File(Environment.getExternalStorageDirectory(), imgName);
//                cropPic(Uri.fromFile(tempFile));

                // 適配 Android7.0+
                cropPic(getImageContentUri(file));
                break;

getImageContentUri() 程式碼如下:

/**
     * 7.0以上獲取裁剪 Uri
     *
     * @param imageFile
     * @return
     */
    private Uri getImageContentUri(File imageFile) {
        String filePath = imageFile.getAbsolutePath();
        Cursor cursor = getContentResolver().query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                new String[]{MediaStore.Images.Media._ID},
                MediaStore.Images.Media.DATA + "=? ",
                new String[]{filePath}, null);

        if (cursor != null && cursor.moveToFirst()) {
            int id = cursor.getInt(cursor
                    .getColumnIndex(MediaStore.MediaColumns._ID));
            Uri baseUri = Uri.parse("content://media/external/images/media");
            return Uri.withAppendedPath(baseUri, "" + id);
        } else {
            if (imageFile.exists()) {
                ContentValues values = new ContentValues();
                values.put(MediaStore.Images.Media.DATA, filePath);
                return getContentResolver().insert(
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
            } else {
                return null;
            }
        }
    }

程式碼適配解決完了,還有一個 FileProvider 問題需要做如下配置。

  1. 在 res 目錄下建立一個名為 xml 的資料夾,並在其下建立一個名為 file_paths.xml 檔案,其內容如下:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <files-path
        name="images"
        path="Android/data/com.example.package.name/files/Pictures/OriPicture/" />
    <external-path
        name="images"
        path="Android/data/com.example.package.name/files/Pictures/OriPicture/" />
    <external-files-path
        name="images"
        path="files/Pictures/OriPicture" />
    <root-path
        name="images"
        path="" />
    <root-path
        name="images"
        path="" />
</paths>
2. 在 AndroidMainfest.xml 中的 application 節點下做如下配置:
<!--FileProvider共享檔案、快取-->
        <provider
            android:name="android.support.v4.content.FileProvider"
            android:authorities="com.cxs.yukumenu.provider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>

其中這裡的 android:authorities 屬性值要和程式碼中 FileProvider.getUriForFile()的第二個引數值保持一致。

注意

  • 程式碼已全部給出,貼上即可使用
  • 本次測試真機:華為5.1.1,一加8.0.0

總結

  1. 機型適配是個細活,但能鍛鍊排查問題和解決問題能力。
  2. 本文的程式碼有很多是可以抽取的,如果是在專案中使用,那麼建議提取出特定功能的程式碼。

相關文章