如何在Flutter应用中实现添加PNG贴纸并保存图片的编辑功能?
Alright, let's walk through exactly how to build this image overlay app—from setup to core features like adding PNG stickers and saving the final image. I'll include code snippets and explain each step clearly so you can follow along.
一、所需依赖包
Most functionality can be handled with Android's native APIs, but we'll use one lightweight library to simplify image loading and memory management:
- Glide: For efficiently loading PNG assets/drawables, handling caching, and avoiding Bitmap memory leaks.
Add this to your app-levelbuild.gradle(Module level):implementation 'com.github.bumptech.glide:glide:4.16.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
二、基础配置与权限
1. Permission Declarations
Add these permissions to your AndroidManifest.xml (adjust for your target Android version):
<!-- Android 12 and below --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <!-- Android 13+ --> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- Optional: For broad storage access (only if needed for legacy devices) --> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
Note: For Android 10+, use Scoped Storage (via MediaStore) to save images instead of writing directly to public directories.
2. Core Layout Example
Create activity_main.xml with a target image area, sticker list, and save button:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <!-- Target image container (supports overlaying stickers) --> <FrameLayout android:id="@+id/target_image_container" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"> <ImageView android:id="@+id/target_image" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="fitCenter" android:background="#f0f0f0" /> <!-- Custom view for dragging/overlaying PNG stickers --> <com.yourpackage.OverlayStickerView android:id="@+id/overlay_view" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout> <!-- Horizontal list of PNG stickers --> <androidx.recyclerview.widget.RecyclerView android:id="@+id/sticker_recycler" android:layout_width="match_parent" android:layout_height="120dp" android:padding="8dp" /> <!-- Save button --> <Button android:id="@+id/save_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Save Composed Image" android:layout_margin="16dp" /> </LinearLayout>
三、Feature Implementation Steps
1. Select & Load Target Image
First, implement the ability to pick an image from the gallery, handling permission requests for different Android versions:
class MainActivity : AppCompatActivity() { private val PICK_IMAGE_REQUEST = 100 private lateinit var targetImageView: ImageView private lateinit var overlayView: OverlayStickerView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) targetImageView = findViewById(R.id.target_image) overlayView = findViewById(R.id.overlay_view) // Trigger image picker when tapping the target image area targetImageView.setOnClickListener { checkPermissionsAndOpenPicker() } // Initialize sticker list and save button initStickerRecycler() initSaveButton() } private fun checkPermissionsAndOpenPicker() { val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_IMAGES } else { Manifest.permission.READ_EXTERNAL_STORAGE } if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { requestPermissions(arrayOf(permission), 101) } else { openImagePicker() } } private fun openImagePicker() { val intent = Intent(Intent.ACTION_GET_CONTENT).apply { type = "image/*" } startActivityForResult(intent, PICK_IMAGE_REQUEST) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == PICK_IMAGE_REQUEST && resultCode == RESULT_OK && data != null) { val imageUri = data.data ?: return // Load target image into ImageView Glide.with(this) .load(imageUri) .into(targetImageView) // Pass URI to overlay view for later composition overlayView.setTargetImageUri(imageUri) } } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { openImagePicker() } else { Toast.makeText(this, "Permission required to select images", Toast.LENGTH_SHORT).show() } } }
2. Display PNG Sticker List
Assume your PNG stickers are stored in the assets/stickers/ directory. Create a RecyclerView adapter to display them:
// Sticker RecyclerView Adapter class StickerAdapter( private val stickerNames: List<String>, private val onStickerClicked: (String) -> Unit ) : RecyclerView.Adapter<StickerAdapter.StickerViewHolder>() { inner class StickerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val stickerImage: ImageView = itemView.findViewById(R.id.sticker_item_image) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StickerViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_sticker, parent, false) return StickerViewHolder(view) } override fun onBindViewHolder(holder: StickerViewHolder, position: Int) { val stickerName = stickerNames[position] // Load sticker from assets using Glide Glide.with(holder.itemView.context) .load("file:///android_asset/stickers/$stickerName") .into(holder.stickerImage) holder.itemView.setOnClickListener { onStickerClicked(stickerName) } } override fun getItemCount(): Int = stickerNames.size } // Initialize sticker list in MainActivity private fun initStickerRecycler() { val recyclerView = findViewById<RecyclerView>(R.id.sticker_recycler) recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) // Get all sticker filenames from assets val stickerNames = assets.list("stickers")?.toList() ?: emptyList() recyclerView.adapter = StickerAdapter(stickerNames) { stickerName -> // Add selected sticker to overlay view overlayView.addSticker(stickerName) } }
Create item_sticker.xml for each list item:
<?xml version="1.0" encoding="utf-8"?> <ImageView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/sticker_item_image" android:layout_width="80dp" android:layout_height="80dp" android:layout_marginHorizontal="8dp" android:scaleType="fitCenter" android:background="#ffffff" />
3. Overlay & Drag PNG Stickers
Create a custom OverlayStickerView to handle sticker placement, dragging, and composition:
class OverlayStickerView(context: Context, attrs: AttributeSet?) : View(context, attrs) { private var targetImageUri: Uri? = null private val stickers = mutableListOf<Sticker>() private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private var activeStickerIndex = -1 private var lastTouchX = 0f private var lastTouchY = 0f data class Sticker(val bitmap: Bitmap, var position: RectF) fun setTargetImageUri(uri: Uri?) { targetImageUri = uri invalidate() } fun addSticker(stickerName: String) { // Load sticker from assets val inputStream = context.assets.open("stickers/$stickerName") val bitmap = BitmapFactory.decodeStream(inputStream) // Place sticker at center of the view by default val rect = RectF( (width - bitmap.width) / 2f, (height - bitmap.height) / 2f, (width + bitmap.width) / 2f, (height + bitmap.height) / 2f ) stickers.add(Sticker(bitmap, rect)) activeStickerIndex = stickers.size - 1 invalidate() } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) canvas ?: return // Draw target image first (if selected) targetImageUri?.let { uri -> val targetBitmap = Glide.with(context) .asBitmap() .load(uri) .submit() .get() val scaledBitmap = Bitmap.createScaledBitmap(targetBitmap, width, height, true) canvas.drawBitmap(scaledBitmap, 0f, 0f, paint) targetBitmap.recycle() scaledBitmap.recycle() } // Draw all stickers on top stickers.forEach { sticker -> canvas.drawBitmap(sticker.bitmap, null, sticker.position, paint) } } // Handle touch events for dragging stickers override fun onTouchEvent(event: MotionEvent?): Boolean { event ?: return false when (event.action) { MotionEvent.ACTION_DOWN -> { // Check if touch is on any sticker activeStickerIndex = stickers.indexOfLast { sticker -> sticker.position.contains(event.x, event.y) } if (activeStickerIndex != -1) { val sticker = stickers[activeStickerIndex] lastTouchX = event.x - sticker.position.left lastTouchY = event.y - sticker.position.top } } MotionEvent.ACTION_MOVE -> { if (activeStickerIndex != -1) { val sticker = stickers[activeStickerIndex] // Update sticker position sticker.position.left = event.x - lastTouchX sticker.position.top = event.y - lastTouchY sticker.position.right = sticker.position.left + sticker.position.width() sticker.position.bottom = sticker.position.top + sticker.position.height() invalidate() } } } return true } // Get the final composed bitmap for saving fun getComposedBitmap(): Bitmap? { targetImageUri ?: return null val targetBitmap = Glide.with(context) .asBitmap() .load(targetImageUri) .submit() .get() val composedBitmap = Bitmap.createBitmap(targetBitmap.width, targetBitmap.height, Bitmap.Config.ARGB_8888) val canvas = Canvas(composedBitmap) canvas.drawBitmap(targetBitmap, 0f, 0f, paint) // Scale sticker positions to match target bitmap size val scaleX = targetBitmap.width.toFloat() / width.toFloat() val scaleY = targetBitmap.height.toFloat() / height.toFloat() stickers.forEach { sticker -> val scaledRect = RectF( sticker.position.left * scaleX, sticker.position.top * scaleY, sticker.position.right * scaleX, sticker.position.bottom * scaleY ) canvas.drawBitmap(sticker.bitmap, null, scaledRect, paint) } targetBitmap.recycle() return composedBitmap } }
4. Save the Composed Image
Implement save functionality that works with Scoped Storage (Android 10+):
// Initialize save button in MainActivity private fun initSaveButton() { findViewById<Button>(R.id.save_button).setOnClickListener { val composedBitmap = overlayView.getComposedBitmap() ?: run { Toast.makeText(this, "Select a target image and add stickers first!", Toast.LENGTH_SHORT).show() return@setOnClickListener } saveImageToGallery(composedBitmap) } } private fun saveImageToGallery(bitmap: Bitmap) { val contentValues = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, "composed_${System.currentTimeMillis()}.png") put(MediaStore.Images.Media.MIME_TYPE, "image/png") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/StickerComposer") } } val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) ?: run { Toast.makeText(this, "Failed to save image", Toast.LENGTH_SHORT).show() return } try { contentResolver.openOutputStream(uri)?.use { outputStream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) Toast.makeText(this, "Image saved to gallery!", Toast.LENGTH_SHORT).show() } } catch (e: IOException) { e.printStackTrace() Toast.makeText(this, "Save failed: ${e.message}", Toast.LENGTH_SHORT).show() } finally { bitmap.recycle() } }
四、Key Notes
- Memory Management: Always call
recycle()on Bitmaps when done, or let Glide handle memory caching to avoid OutOfMemoryErrors. - PNG Transparency: The default
Paintsettings preserve alpha channels, so your PNG stickers will retain transparency. - Screen Adaptation: The
OverlayStickerViewscales sticker positions to match the target image's actual resolution, ensuring the saved image matches what's on screen. - Permission Compatibility: The code handles permission differences between Android versions to avoid crashes.
内容的提问来源于stack exchange,提问作者Vishnu ES

