Hỗ trợ các nền tảng có thể đổi kích thước trong ứng dụng máy ảnh

1. Giới thiệu

Lần cập nhật gần nhất: Ngày 27 tháng 10 năm 2022

Tại sao cần có một nền tảng có thể đổi kích thước?

Trước đây, ứng dụng của bạn có thể chỉ phải hiển thị trong cùng một cửa sổ trong suốt vòng đời của nó.

Tuy nhiên, với sự ra đời của các kiểu dáng mới, chẳng hạn như thiết bị có thể gập lại và các chế độ hiển thị mới như nhiều cửa sổ và nhiều màn hình, điều này không còn đúng nữa.

Cụ thể, hãy cùng xem một số điểm quan trọng nhất cần lưu ý khi phát triển ứng dụng nhắm vào phân khúc thiết bị có màn hình lớn và có thể gập lại:

  • Đừng cho rằng ứng dụng sẽ luôn hiển thị ở chế độ dọc. Việc yêu cầu hướng cố định vẫn được hỗ trợ trong Android 12L, nhưng chúng tôi sẽ cung cấp cho nhà sản xuất thiết bị tuỳ chọn ghi đè yêu cầu của ứng dụng để có hướng ưu tiên.
  • Đừng cho rằng ứng dụng có bất kỳ tỷ lệ khung hình hoặc kích thước cố định nào. Ngay cả khi bạn đặt resizeableActivity = "false", ứng dụng của bạn có thể được dùng ở chế độ nhiều cửa sổ trên màn hình lớn (>=600dp) trên API cấp 31 trở lên.
  • Đừng cho rằng mối quan hệ giữa hướng của màn hình và máy ảnh không thay đổi. Theo Tài liệu định nghĩa về khả năng tương thích với Android, bạn "PHẢI định hướng cảm biến hình ảnh của máy ảnh để chiều dài của máy ảnh phù hợp với chiều dài của màn hình". Kể từ API cấp 32, ứng dụng máy ảnh truy vấn hướng trên các thiết bị có thể gập lại sẽ nhận được giá trị thay đổi linh động tuỳ theo thiết bị/trạng thái gập.
  • Đừng cho rằng kích thước của phần lồng ghép không thể thay đổi. Thanh tác vụ mới này sẽ được báo cáo đến các ứng dụng dưới dạng phần lồng ghép. Khi được dùng với thao tác bằng cử chỉ, thanh tác vụ có thể ẩn và hiện một cách linh động.
  • Đừng cho rằng ứng dụng của bạn có quyền truy cập độc quyền vào máy ảnh. Khi ứng dụng ở chế độ nhiều cửa sổ, các ứng dụng khác có thể truy cập vào tài nguyên dùng chung như máy ảnh và micrô.

Đã đến lúc đảm bảo ứng dụng máy ảnh của bạn hoạt động tốt trong mọi tình huống bằng cách tìm hiểu cách biến đổi đầu ra máy ảnh để phù hợp với những nền tảng có thể thay đổi kích thước, cũng như cách sử dụng các API mà Android cung cấp để xử lý nhiều trường hợp sử dụng.

Sản phẩm bạn sẽ tạo ra

Trong lớp học lập trình này, bạn sẽ xây dựng một ứng dụng đơn giản để hiển thị bản xem trước của máy ảnh. Ban đầu, bạn sẽ bắt đầu với một ứng dụng máy ảnh cơ bản, khoá hướng và tự khai báo là không thể đổi kích thước. Bạn sẽ thấy hành vi của ứng dụng này trên Android 12L.

Sau đó, bạn sẽ phải cập nhật mã nguồn để đảm bảo bản xem trước luôn hiển thị tốt trong mọi trường hợp. Nhờ đó, ứng dụng máy ảnh sẽ xử lý chính xác các thay đổi về cấu hình và tự động biến đổi nền tảng để khớp với bản xem trước.

1df0acf495b0a05a.png

Kiến thức bạn sẽ học được

  • Cách hiển thị bản xem trước của Camera2 trên nền tảng Android
  • Mối quan hệ giữa cảm biến hướng, xoay màn hình và tỷ lệ khung hình
  • Cách biến đổi nền tảng để khớp với tỷ lệ khung hình của bản xem trước máy ảnh và xoay màn hình

Bạn cần có

  • Phiên bản Android Studio gần nhất
  • Kiến thức cơ bản về cách phát triển ứng dụng Android
  • Kiến thức cơ bản về API Camera2
  • Một thiết bị hoặc trình mô phỏng chạy Android 12L

2. Thiết lập

Lấy mã khởi động

Để hiểu hành vi trên Android 12L, bạn sẽ bắt đầu bằng một ứng dụng máy ảnh khoá hướng và tự khai báo không thể thay đổi kích thước.

Nếu đã cài đặt Git, bạn có thể chỉ cần chạy lệnh bên dưới. Để kiểm tra xem Git đã được cài đặt hay chưa, hãy nhập git --version vào dòng lệnh hoặc cửa sổ dòng lệnh và xác minh rằng mã này được thực thi đúng cách.

git clone https://github.com/googlecodelabs/android-camera2-preview.git

Nếu chưa có Git, bạn có thể nhấp vào nút sau để tải tất cả mã cho lớp học lập trình này:

Mở mô-đun đầu tiên

Trong Android Studio, hãy mở học phần đầu tiên trong /step1.

Android Studio sẽ nhắc bạn đặt đường dẫn SDK. Bạn nên làm theo đề xuất về việc cập nhật các công cụ IDE và SDK nếu gặp phải vấn đề nào đó.

302f1fb5070208c7.png

Nếu bạn được yêu cầu phải sử dụng phiên bản Gradle mới nhất, hãy tiếp tục và cập nhật phiên bản đó.

Chuẩn bị thiết bị

Kể từ ngày ra mắt của lớp học lập trình này, chúng tôi giới hạn một số thiết bị thực tế có thể chạy Android 12L.

Bạn có thể tìm xem danh sách thiết bị và hướng dẫn cài đặt 12L tại đây: https://developer.android.com/about/versions/12/12L/get

Bất cứ khi nào có thể, hãy sử dụng một thiết bị thực tế để kiểm thử các ứng dụng máy ảnh. Tuy nhiên, trong trường hợp muốn sử dụng trình mô phỏng, hãy nhớ tạo một ứng dụng có màn hình lớn (ví dụ: Pixel C) và dùng API cấp 32.

Chuẩn bị một đối tượng trong khung hình

Khi làm việc với máy ảnh, tôi muốn có một đối tượng chuẩn tôi có thể hướng máy ảnh đến để hiện sự khác biệt về cài đặt, hướng và tỷ lệ.

Đối với lớp học lập trình này, tôi sẽ sử dụng phiên bản in của ảnh có kích thước vuông này. 66e5d83317364e67.png

Trong mọi trường hợp, mũi tên không trỏ đến đỉnh hoặc hình vuông có một hình dạng khác . . . có thể sai ở bước nào đó!

3. Chạy và quan sát

Đặt thiết bị ở chế độ chân dung và chạy mã trên học phần 1. Hãy nhớ cho phép ứng dụng Lớp học lập trình 2 cho Camera2 chụp ảnh và quay video khi dùng ứng dụng. Như bạn có thể thấy, bản xem trước được hiển thị chính xác và sử dụng không gian màn hình một cách hiệu quả.

Bây giờ, hãy xoay thiết bị sang chế độ ngang:

46f2d86b060dc15a.png

Chắc chắn là không ổn rồi. Bây giờ, hãy nhấp vào nút làm mới ở góc dưới cùng bên phải.

b8fbd7a793cb6259.png

Nó nhìn ổn hơn một chút, nhưng vẫn chưa tối ưu.

Những gì bạn thấy là hành vi của chế độ tương thích của Android 12L. Những ứng dụng khoá hướng dọc có thể tạo ra giao diện dạng hòm thư (letterboxed) khi thiết bị xoay ngang và mật độ màn hình cao hơn 600 dp.

Mặc dù chế độ này giữ nguyên tỷ lệ khung hình gốc, nhưng cũng đem lại trải nghiệm người dùng dưới mức tối ưu, vì phần lớn không gian màn hình chưa được sử dụng.

Hơn nữa, trong trường hợp này bản xem trước bị xoay sai 90 độ.

Sau đó, đưa thiết bị trở về chế độ dọc và bắt đầu chế độ chia đôi màn hình.

Bạn có thể đổi kích thước cửa sổ bằng cách kéo thanh phân cách ở giữa.

Xem việc thay đổi kích thước ảnh hưởng đến bản xem trước của máy ảnh như thế nào. Nó có bị méo hình không? Nó có duy trì cùng một tỷ lệ khung hình không?

4. Bản sửa lỗi nhanh

Vì chế độ tương thích chỉ được kích hoạt cho những ứng dụng khoá hướng và không thể thay đổi kích thước, nên bạn chỉ cần cập nhật cờ trong tệp kê khai để né nó.

Hãy tiếp tục và thử thực hiện:

step1/AndroidManifest.xml

<activity
    android:name=".CameraActivity"
    android:exported="true"
    android:resizeableActivity="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

Bây giờ, hãy tạo ứng dụng và chạy lại theo hướng ngang. Bạn sẽ thấy như sau:

f5753af5a9e44d2f.png

Mũi tên không trỏ đến trên cùng và đó không phải là hình vuông!

Do không được thiết kế để hoạt động ở chế độ nhiều cửa sổ hoặc theo các hướng khác nhau, ứng dụng không kỳ vọng bất kỳ thay đổi nào trong kích thước cửa sổ, dẫn đến các vấn đề bạn vừa gặp phải.

5. Xử lý thay đổi về cấu hình

Hãy bắt đầu bằng cách thông báo cho hệ thống biết chúng ta muốn tự xử lý các thay đổi về cấu hình. Mở step1/AndroidManifest.xml và thêm các dòng sau:

step1/AndroidManifest.xml

<activity
    android:name=".CameraActivity"
    android:exported="true"
    android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
    android:resizeableActivity="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

Giờ đây, bạn cũng nên cập nhật step1/CameraActivity.kt để tạo lại CameraCaptureSession mỗi khi kích thước bề mặt thay đổi.

Chuyển đến dòng 232 và gọi hàm createCaptureSession():

step1/CameraActivity.kt

override fun onSurfaceTextureSizeChanged(
    surface: SurfaceTexture,
    width: Int,
    height: Int
) {
    createCaptureSession()
}

Có một lưu ý rằng: onSurfaceTextureSizeChanged không được gọi sau khi xoay 180 độ (kích thước không thay đổi!). Sự kiện này cũng không kích hoạt onConfigurationChanged, vì vậy, chúng ta chỉ có thể tạo thực thể của DisplayListener và kiểm tra xoay 180 độ. Vì thiết bị có 4 hướng (dọc, ngang, dọc đảo ngược và ngang đảo ngược) được định nghĩa bằng các số nguyên 0, 1, 2 và 3, chúng ta cần độ kiểm tra chênh lệch xoay là 2.

Thêm mã sau:

step1/CameraActivity.kt

/** DisplayManager to listen to display changes */
private val displayManager: DisplayManager by lazy {
    applicationContext.getSystemService(DISPLAY_SERVICE) as DisplayManager
}

/** Keeps track of display rotations */
private var displayRotation = 0

...

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    displayManager.registerDisplayListener(displayListener, mainLooperHandler)
}

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    displayManager.unregisterDisplayListener(displayListener)
}

private val displayListener = object : DisplayManager.DisplayListener {
    override fun onDisplayAdded(displayId: Int) {}
    override fun onDisplayRemoved(displayId: Int) {}
    override fun onDisplayChanged(displayId: Int) {
        val difference = displayManager.getDisplay(displayId).rotation - displayRotation
        displayRotation = displayManager.getDisplay(displayId).rotation

        if (difference == 2 || difference == -2) {
            createCaptureSession()
        }
    }
}

Giờ đây, chúng ta chắc chắn phiên chụp sẽ được tạo lại trong mọi trường hợp. Đã đến lúc tìm hiểu về mối quan hệ bí ẩn giữa hướng máy ảnh và độ xoay màn hình.

6. Hướng của cảm biến và độ xoay màn hình

Chúng ta gọi hướng tự nhiên là hướng mà người dùng có xu hướng "tự nhiên" sử dụng thiết bị. Ví dụ: hướng tự nhiên thường là ngang đối với máy tính xách tay và dọc với điện thoại. Đối với máy tính bảng, nó có thể là cả hai hướng.

Bắt đầu từ định nghĩa này, chúng ta có thể xác định hai khái niệm khác.

1f9cf3248b95e534.png

Chúng ta gọi hướng máy ảnh là góc giữa cảm biến máy ảnh và hướng tự nhiên của thiết bị. Điều này có thể phụ thuộc vào cách máy ảnh được gắn trên thiết bị và cảm biến phải luôn khớp với cạnh dài của màn hình (xem phần CDD).

Xét thấy khó có thể xác định cạnh dài cho một thiết bị có thể gập lại — vì hình dạng của nó có thể biến đổi — kể từ API cấp 32, trường này không còn tĩnh, nhưng nó có thể được truy xuất từ đối tượng CameraCharacteristics một cách linh động.

Một khái niệm khác là xoay thiết bị. Chỉ số này đo mức độ xoay của thiết bị so với hướng tự nhiên.

Vì chúng ta thường chỉ muốn xử lý 4 hướng khác nhau, nên ta chỉ có thể xét đến các góc là bội số của 90 và lấy thông số này bằng cách nhân giá trị được trả về từ Display.getRotation() với 90.

Theo mặc định, TextureView đã chỉnh sửa hướng máy ảnh, nhưng không xử lý việc xoay màn hình, dẫn đến bản xem trước bị xoay không đúng.

Điều này có thể được giải quyết bằng cách xoay SurfaceTexture mục tiêu. Hãy cập nhật hàm CameraUtils.buildTargetTexture để chấp nhận tham số surfaceRotation: Int và áp dụng phép biến đổi cho bề mặt:

step1/CameraUtils.kt

fun buildTargetTexture(
    containerView: TextureView,
    characteristics: CameraCharacteristics,
    surfaceRotation: Int
): SurfaceTexture? {

    val previewSize = findBestPreviewSize(Size(containerView.width, containerView.height), characteristics)

    val surfaceRotationDegrees = surfaceRotation * 90

    val halfWidth = containerView.width / 2f
    val halfHeight = containerView.height / 2f

    val matrix = Matrix()

    // Rotate to compensate display rotation
    matrix.postRotate(
        -surfaceRotationDegrees.toFloat(),
        halfWidth,
        halfHeight
    )

    containerView.setTransform(matrix)

    return containerView.surfaceTexture?.apply {
        setDefaultBufferSize(previewSize.width, previewSize.height)
    }
}

Sau đó, bạn có thể gọi phiên bản này bằng cách sửa đổi dòng lệnh 138 của CameraActivity theo cách sau:

step1/CameraActivity.kt

val targetTexture = CameraUtils.buildTargetTexture(
textureView, cameraManager.getCameraCharacteristics(cameraID))

Bây giờ, việc chạy ứng dụng dẫn đến một bản xem trước như sau:

1566c3f9e5089a35.png

Mũi tên giờ sẽ trỏ đến trên cùng, nhưng vùng chứa vẫn không phải là hình vuông. Hãy xem cách khắc phục vấn đề này ở bước cuối cùng.

Điều chỉnh tỷ lệ kính ngắm

Bước cuối cùng là điều chỉnh tỷ lệ bề mặt cho phù hợp với tỷ lệ khung hình của đầu ra máy ảnh.

Vấn đề của bước trước đó xảy ra vì theo mặc định, TextureView sẽ điều chỉnh tỷ lệ nội dung cho phù hợp với toàn bộ cửa sổ. Cửa sổ này có thể có tỷ lệ khung hình khác với bản xem trước của máy ảnh, do đó cửa sổ có thể bị kéo giãn hoặc bị méo.

Chúng ta có thể khắc phục vấn đề này trong 2 bước dưới đây:

  • Tính toán các hệ số tỷ lệ TextureView áp dụng cho chính nó theo mặc định và đảo ngược phép biến đổi đó
  • Tính toán và áp dụng hệ số tỷ lệ phù hợp (cần phải giống nhau cho cả trục x và y)

Để tính toán hệ số tỷ lệ chính xác, chúng ta cần tính đến sự khác biệt giữa hướng máy ảnh và độ xoay màn hình. Mở step1/CameraUtils.kt và thêm hàm sau để tính toán mức xoay tương đối giữa hướng cảm biến và độ xoay màn hình:

step1/CameraUtils.kt

/**
 * Computes the relative rotation between the sensor orientation and the display rotation.
 */
private fun computeRelativeRotation(
    characteristics: CameraCharacteristics,
    deviceOrientationDegrees: Int
): Int {
    val sensorOrientationDegrees =
        characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0

    // Reverse device orientation for front-facing cameras
    val sign = if (characteristics.get(CameraCharacteristics.LENS_FACING) ==
        CameraCharacteristics.LENS_FACING_FRONT
    ) 1 else -1

    return (sensorOrientationDegrees - (deviceOrientationDegrees * sign) + 360) % 360
}

Việc biết được giá trị trả về từ computeRelativeRotation rất quan trọng vì giá trị này giúp chúng ta nắm được liệu bản xem trước ban đầu đã được xoay hay chưa trước khi được tính tỷ lệ.

Ví dụ: đối với điện thoại theo hướng tự nhiên, đầu ra máy ảnh sẽ có hình ngang và xoay được 90 độ trước khi hiển thị trên màn hình.

Mặt khác, theo hướng tự nhiên đối với Chromebook, đầu ra máy ảnh được hiển thị trực tiếp trên màn hình không phải có bất kỳ việc xoay bổ sung nào.

Hãy xem lại các trường hợp sau:

4e3a61ea9796a914.png Trong trường hợp thứ hai (ở giữa), trục x của đầu ra máy ảnh hiển thị trên trục y của màn hình và ngược lại. Điều này có nghĩa là chiều rộng và chiều cao của đầu ra máy ảnh đang bị đảo ngược trong phép biến đổi. Trong các trường hợp khác, chúng vẫn được giữ nguyên, mặc dù vẫn phải xoay trong tình huống thứ ba.

Chúng ta có thể khái quát hoá các trường hợp đó bằng công thức:

val isRotationRequired =
        computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0

Với thông tin này, giờ đây chúng ta có thể cập nhật hàm để mở rộng kích thước nền tảng:

step1/CameraUtils.kt

fun buildTargetTexture(
        containerView: TextureView,
        characteristics: CameraCharacteristics,
        surfaceRotation: Int
    ): SurfaceTexture? {

        val surfaceRotationDegrees = surfaceRotation * 90
        val windowSize = Size(containerView.width, containerView.height)
        val previewSize = findBestPreviewSize(windowSize, characteristics)
        val sensorOrientation =
            characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
        val isRotationRequired =
            computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0

        /* Scale factor required to scale the preview to its original size on the x-axis */
        var scaleX = 1f
        /* Scale factor required to scale the preview to its original size on the y-axis */
        var scaleY = 1f

        if (sensorOrientation == 0) {
            scaleX =
                if (!isRotationRequired) {
                    windowSize.width.toFloat() / previewSize.height
                } else {
                    windowSize.width.toFloat() / previewSize.width
                }

            scaleY =
                if (!isRotationRequired) {
                    windowSize.height.toFloat() / previewSize.width
                } else {
                    windowSize.height.toFloat() / previewSize.height
                }
        } else {
            scaleX =
                if (isRotationRequired) {
                    windowSize.width.toFloat() / previewSize.height
                } else {
                    windowSize.width.toFloat() / previewSize.width
                }

            scaleY =
                if (isRotationRequired) {
                    windowSize.height.toFloat() / previewSize.width
                } else {
                    windowSize.height.toFloat() / previewSize.height
                }
        }

        /* Scale factor required to fit the preview to the TextureView size */
        val finalScale = max(scaleX, scaleY)
        val halfWidth = windowSize.width / 2f
        val halfHeight = windowSize.height / 2f

        val matrix = Matrix()

        if (isRotationRequired) {
            matrix.setScale(
                1 / scaleX * finalScale,
                1 / scaleY * finalScale,
                halfWidth,
                halfHeight
            )
        } else {
            matrix.setScale(
                windowSize.height / windowSize.width.toFloat() / scaleY * finalScale,
                windowSize.width / windowSize.height.toFloat() / scaleX * finalScale,
                halfWidth,
                halfHeight
            )
        }

        // Rotate to compensate display rotation
        matrix.postRotate(
            -surfaceRotationDegrees.toFloat(),
            halfWidth,
            halfHeight
        )

        containerView.setTransform(matrix)

        return containerView.surfaceTexture?.apply {
            setDefaultBufferSize(previewSize.width, previewSize.height)
        }
    }

Tạo và chạy ứng dụng rồi chiêm ngưỡng bản xem trước mới của máy ảnh!

Bật mí thêm cho bạn: thay đổi ảnh động mặc định

Nếu muốn tránh ảnh động mặc định trong chế độ xoay (ảnh động này có thể không điển hình với các ứng dụng máy ảnh,) thì bạn có thể thay đổi ảnh động đó bằng một ảnh động nhảy nhanh để chuyển đổi mượt mà hơn bằng cách thêm mã sau vào phương thức onCreate() của hoạt động:

val windowParams: WindowManager.LayoutParams = window.attributes
windowParams.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
window.attributes = windowParams

7. Xin chúc mừng

Kiến thức bạn học được:

  • Cách các ứng dụng không được tối ưu hoá hoạt động trên Android 12L ở chế độ tương thích
  • Cách xử lý các thay đổi về cấu hình
  • Sự khác biệt giữa các khái niệm như hướng máy ảnh, độ xoay màn hình và hướng tự nhiên của thiết bị
  • Hành vi mặc định của TextureView
  • Cách điều chỉnh tỷ lệ và xoay nền tảng để hiển thị chính xác bản xem trước của máy ảnh trong mọi tình huống!

Tài liệu đọc thêm

Tài liệu tham khảo