Files
RecipesCam/1_FIX_CAMERA.md
2026-07-17 09:33:24 +07:00

7.7 KiB

Dưới đây là toàn bộ nội dung kế hoạch đã được chuyển đổi sang định dạng Markdown chuẩn, tối ưu hóa cấu trúc scannable với tiêu đề phân cấp, bảng biểu, danh sách và các khối code block được định dạng rõ ràng.


Kế Hoạch Sửa Lỗi & Tối Ưu Hóa Ứng Dụng Recipes Camera

Tài liệu này tập trung giải quyết triệt để 4 vấn đề kỹ thuật lớn được phát hiện trên phiên bản di động hiện tại của ứng dụng Recipes Camera (dựa trên giao diện thực tế trong tệp tin Screenshot_2026-07-17-09-22-29-895_com.locphamtran.recipescamera.jpg). Phương án sửa lỗi ưu tiên sử dụng các bộ thiết lập có sẵn, tối ưu hóa hiệu năng GPU và cải thiện trải nghiệm người dùng (UX).


1. Khắc phục lỗi: Kính ngắm (Viewfinder) Camera Native không áp dụng hiệu ứng Recipes

Nguyên nhân lỗi

Trên thiết bị di động, luồng preview từ phần cứng camera (Camera của Expo hoặc VisionCamera) được render trực tiếp lên một bề mặt native riêng (Native Surface). Do đó, các bộ lọc CSS hoặc Canvas thông thường trong JavaScript không thể tác động lên Live Viewfinder này, dẫn đến việc kính ngắm vẫn hiển thị màu gốc dù người dùng đã chọn recipe khác.

Giải pháp kỹ thuật (Sử dụng Skia Frame Processor có sẵn)

Thay vì dùng luồng preview thô, chúng ta sẽ định tuyến luồng frame của camera đi qua GPU của thiết bị bằng cách kết hợp React Native Vision Camera (v3/v4)React Native Skia:

  • Sử dụng Frame Processor: Kích hoạt Frame Processor từ react-native-vision-camera.
  • Vẽ trực tiếp lên Skia Canvas: Truyền các Frame từ camera vào làm texture cho Skia Canvas thời gian thực.
  • Áp bộ lọc phần cứng của Skia: Sử dụng cấu trúc lọc màu ma trận có sẵn của Skia (Skia.ColorFilter.Matrix) đè trực tiếp lên Canvas ngắm.
// Trích đoạn giải pháp xử lý luồng Frame thời gian thực trên GPU di động
import { Camera, useFrameProcessor } from 'react-native-vision-camera';
import { Canvas, Image, ColorMatrix } from '@shopify/react-native-skia';

// Trong Component hiển thị Viewfinder
const frameProcessor = useFrameProcessor((frame) => {
  'worklet';
  // Đưa frame camera trực tiếp vào luồng xử lý của Skia GPU
  const skiaImage = frame.toSkiaImage();
  // Render và áp dụng ColorMatrix tức thì ở tốc độ 60 FPS
}, [currentMatrix]);


2. Nâng cấp UX: Thay đổi nút Shutter thành nút Lưu ảnh khi ở chế độ Thư viện

Vấn đề UX

Khi người dùng tải ảnh từ thư viện lên ứng dụng để chỉnh sửa (Library Mode), nút bấm chính ở giữa dưới cùng vẫn là nút chụp ảnh (nút Shutter trắng tròn lớn), gây bối rối vì người dùng không biết nhấn vào đâu để lưu bức ảnh đã áp công thức màu mới.

Giải pháp kỹ thuật (Giao diện động)

Chúng ta sẽ chuyển đổi trạng thái của nút điều khiển chính dựa vào giá trị của biến trạng thái nguồn ảnh currentInputSource:

Trạng thái currentInputSource Giao diện nút bấm hiển thị Hành vi khi kích hoạt (onPress)
'camera' Nút Shutter trắng tròn cổ điển để chụp ảnh native. Chạy hàm chụp ảnh handleTakePhoto
'library' Nút Lưu ảnh (Save/Export Button) với icon tải xuống nổi bật (download/save). Chạy trực tiếp hàm kết xuất và lưu ảnh captureAndSave()
// Logic kết xuất giao diện động tại thanh điều khiển camera
<TouchableOpacity 
  onPress={currentInputSource === 'camera' ? handleTakePhoto : captureAndSave}
  className="relative items-center justify-center"
>
  {currentInputSource === 'camera' ? (
    // Nút Shutter chụp ảnh
    <View className="w-16 h-16 rounded-full bg-white border-4 border-black" />
  ) : (
    // Nút Lưu ảnh khi chỉnh sửa ảnh từ thư viện
    <View className="w-16 h-16 rounded-full bg-amber-500 items-center justify-center">
      <DownloadIcon stroke="#000" size={28} />
    </View>
  )}
</TouchableOpacity>


3. Khắc phục lỗi: GPS Geotag Watermark không đọc được tọa độ từ ảnh thư viện

Nguyên nhân lỗi

Mã nguồn hiện tại chỉ mô phỏng việc sinh GPS giả lập dựa trên tên tệp tin ảnh hoặc lấy GPS thời gian thực từ cảm biến điện thoại. Khi người dùng nạp một ảnh cũ có chứa dữ liệu GPS từ thư viện, ứng dụng không trích xuất được tọa độ thực tế này.

Giải pháp kỹ thuật (Sử dụng API Native có sẵn)

Khi chọn ảnh từ thư viện bằng gói Media Library, chúng ta sẽ không dùng trực tiếp URI tạm thời, mà dùng mã định danh tài sản (assetId) để yêu cầu hệ thống cung cấp siêu dữ liệu đầy đủ bao gồm dữ liệu vị trí:

  • Sử dụng expo-media-library: Gọi hàm MediaLibrary.getAssetInfoAsync(assetId) có sẵn của Expo.
  • Đọc thuộc tính Location: API native này tự động trả về đối tượng chứa vị trí chính xác:
"location": {
  "latitude": 15.9000,
  "longitude": 108.1500
}

  • Mã hóa địa danh ngược (Reverse Geocoding): Gửi tọa độ này lên OpenStreetMap API để hiển thị tên địa danh hoài cổ trên ảnh (Ví dụ: 📍 PHỐ CỔ HỘI AN, VIETNAM).

4. Khắc phục lỗi: Ứng dụng tự reset về chế độ Camera khi thu nhỏ (App Lifecycle State)

Nguyên nhân lỗi

Khi ứng dụng bị đẩy vào trạng thái chạy ngầm (Background) và bật lại (Active), hệ điều hành có thể giải phóng bộ nhớ tạm thời hoặc cơ chế khởi tạo camera bị kích hoạt lại từ đầu, khiến ứng dụng tự động reset về chế độ Native Camera và xóa sạch bức ảnh người dùng đang chỉnh sửa dở.

Giải pháp kỹ thuật (Lưu giữ trạng thái bằng AppState & AsyncStorage)

Sử dụng API AppState có sẵn của React Native kết hợp với cơ chế lưu trữ để đóng băng trạng thái làm việc hiện tại:

  1. Lắng nghe Lifecycle: Theo dõi trạng thái ứng dụng bằng AppState.addEventListener.
  2. Đóng băng khi thu nhỏ (Pause / Background): Lưu trữ các biến currentInputSource, đường dẫn ảnh tạm currentImageUri, Recipe đang chọn và các thông số tùy chỉnh IQ hiện tại vào bộ nhớ lưu trữ AsyncStorage.
  3. Khôi phục khi khởi động lại (Active): Trong hàm useEffect khởi tạo ứng dụng, tiến hành đọc lại các dữ liệu đã đóng băng từ AsyncStorage. Nếu người dùng đang chỉnh sửa dở ảnh thư viện, ứng dụng sẽ khôi phục chính xác bức ảnh cùng các thanh slider ở nguyên vị trí cũ.
useEffect(() => {
  const subscription = AppState.addEventListener('change', async (nextAppState) => {
    if (nextAppState === 'background' || nextAppState === 'inactive') {
      // Đóng băng và lưu trữ trạng thái hiện tại xuống bộ nhớ tạm
      await AsyncStorage.setItem('saved_session', JSON.stringify({
        source: currentInputSource,
        imageUri: currentImageUri,
        adjustments: adjustments
      }));
    }
  });
  return () => subscription.remove();
}, [currentInputSource, currentImageUri, adjustments]);