已开启
fix(ohos): 等待图片压缩完成后再拉起相册保存框 #10
李唐创建于 8月25日
fix(ohos): 等待图片压缩完成后再拉起相册保存框 #10
已开启
共 1 个文件变更+95-34
| @@ -88,51 +88,92 @@ export default class ImageGallerySaverPlusPlugin implements FlutterPlugin, Metho | |||
| 88 | async onMethodCall(call: MethodCall, result: MethodResult) { | 88 | async onMethodCall(call: MethodCall, result: MethodResult) { |
| 89 | switch (call.method) { | 89 | switch (call.method) { |
| 90 | case "saveFileToGallery": | 90 | case "saveFileToGallery": |
| 91 | - if (this.noFirstInvoke) { | 91 | + await this.runExclusiveSave(result, () => { |
| 92 | - return; | 92 | + let fileName: string = call.argument("name"); |
| 93 | - } | 93 | + let uri: string = call.argument("file"); |
| 94 | - this.noFirstInvoke = true; | 94 | + return this.saveFileToGallery(fileName, uri); |
| 95 | - let FileName: string = call.argument("name"); | 95 | + }); |
| 96 | - let uri: string = call.argument("file"); | ||
| 97 | - result.success(await this.saveFileToGallery(FileName, uri)); | ||
| 98 | - this.noFirstInvoke = false; | ||
| 99 | break; | 96 | break; |
| 100 | case "saveImageToGallery": | 97 | case "saveImageToGallery": |
| 101 | - if (this.noFirstInvoke) { | 98 | + await this.runExclusiveSave(result, () => { |
| 102 | - return; | 99 | + let imageByte: Uint8Array = call.argument("imageBytes"); |
| 103 | - } | 100 | + let quality: number = call.argument("quality"); |
| 104 | - this.noFirstInvoke = true; | 101 | + let imageName: string = call.argument("name"); |
| 105 | - let imageByte: Uint8Array = call.argument("imageBytes"); | 102 | + return this.saveImageToGallery(imageByte, quality, imageName); |
| 106 | - let quality: number = call.argument("quality"); | 103 | + }); |
| 107 | - let ImageName: string = call.argument("name"); | ||
| 108 | - result.success(await this.saveImageToGallery(imageByte, quality, ImageName)); | ||
| 109 | - this.noFirstInvoke = false; | ||
| 110 | break; | 104 | break; |
| 111 | default: | 105 | default: |
| 112 | result.notImplemented(); | 106 | result.notImplemented(); |
| 113 | } | 107 | } |
| 114 | } | 108 | } |
| 115 | 109 | ||
| 110 | + private async runExclusiveSave( | ||
| 111 | + result: MethodResult, | ||
| 112 | + action: () => Promise<ResultMessage> | ||
| 113 | + ): Promise<void> { | ||
| 114 | + if (this.noFirstInvoke) { | ||
| 115 | + result.success(new ResultMessage(null, "save in progress", false)); | ||
| 116 | + return; | ||
| 117 | + } | ||
| 118 | + this.noFirstInvoke = true; | ||
| 119 | + try { | ||
| 120 | + result.success(await action()); | ||
| 121 | + } catch (err) { | ||
| 122 | + const e = err as BusinessError; | ||
| 123 | + result.success(new ResultMessage(null, | ||
| 124 | + `save failed, errCode is ${e.code}, errMsg is ${e.message}`, false)); | ||
| 125 | + } finally { | ||
| 126 | + this.noFirstInvoke = false; | ||
| 127 | + } | ||
| 128 | + } | ||
| 129 | + | ||
| 116 | // 保存图片 | 130 | // 保存图片 |
| 117 | async saveImageToGallery(imageByte: Uint8Array, quality: number, name: string): Promise<ResultMessage> { | 131 | async saveImageToGallery(imageByte: Uint8Array, quality: number, name: string): Promise<ResultMessage> { |
| 118 | if (this.context == null || this.context == undefined) { | 132 | if (this.context == null || this.context == undefined) { |
| 119 | return new ResultMessage(null, "applicationContext is null", false); | 133 | return new ResultMessage(null, "applicationContext is null", false); |
| 120 | } | 134 | } |
| 121 | - let imageSource: image.ImageSource = image.createImageSource(imageByte.buffer); | 135 | + if (imageByte == null || imageByte.length === 0) { |
| 122 | - let packOpts: image.PackingOption = { format: "image/png", quality: quality }; | 136 | + return new ResultMessage(null, "parameters error", false); |
| 123 | - const filePath: string = this.context?.filesDir + "/imageLocal.png"; | 137 | + } |
| 124 | - let file = fs.openSync(filePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE); | 138 | + |
| 125 | - const imagePackerObj: image.ImagePacker = image.createImagePacker(); | 139 | + const title = this.sanitizeTitle(name); |
| 126 | - imagePackerObj.packToFile(imageSource, file.fd, packOpts, (err: BusinessError) => { | 140 | + const filePath: string = `${this.context.filesDir}/image_${Date.now()}.png`; |
| 127 | - if (err) { | 141 | + let file: fs.File | null = null; |
| 128 | - console.error(`Failed to pack the image to file.code ${err.code},message is ${err.message}`); | 142 | + let imageSource: image.ImageSource | null = null; |
| 129 | - } else { | 143 | + let imagePackerObj: image.ImagePacker | null = null; |
| 130 | - console.info('Succeeded in packing the image to file.'); | 144 | + try { |
| 145 | + file = fs.openSync(filePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC); | ||
| 146 | + imageSource = image.createImageSource(imageByte.buffer); | ||
| 147 | + imagePackerObj = image.createImagePacker(); | ||
| 148 | + const packOpts: image.PackingOption = { format: "image/png", quality: quality ?? 80 }; | ||
| 149 | + await new Promise<void>((resolve, reject) => { | ||
| 150 | + imagePackerObj!.packToFile(imageSource, file!.fd, packOpts, (err: BusinessError) => { | ||
| 151 | + if (err) { | ||
| 152 | + reject(err); | ||
| 153 | + } else { | ||
| 154 | + resolve(); | ||
| 155 | + } | ||
| 156 | + }); | ||
| 157 | + }); | ||
| 158 | + } catch (err) { | ||
| 159 | + const e = err as BusinessError; | ||
| 160 | + return new ResultMessage(null, | ||
| 161 | + `Failed to pack the image to file.code ${e.code},message is ${e.message}`, false); | ||
| 162 | + } finally { | ||
| 163 | + if (imagePackerObj) { | ||
| 164 | + imagePackerObj.release(); | ||
| 131 | } | 165 | } |
| 132 | - }) | 166 | + if (imageSource) { |
| 167 | + imageSource.release(); | ||
| 168 | + } | ||
| 169 | + if (file) { | ||
| 170 | + fs.closeSync(file); | ||
| 171 | + } | ||
| 172 | + } | ||
| 173 | + | ||
| 133 | let photoCreationConfigs: Array<photoAccessHelper.PhotoCreationConfig> = [ | 174 | let photoCreationConfigs: Array<photoAccessHelper.PhotoCreationConfig> = [ |
| 134 | { | 175 | { |
| 135 | - title: name, | 176 | + title: title, |
| 136 | fileNameExtension: "png", | 177 | fileNameExtension: "png", |
| 137 | photoType: photoAccessHelper.PhotoType.IMAGE, | 178 | photoType: photoAccessHelper.PhotoType.IMAGE, |
| 138 | subtype: photoAccessHelper.PhotoSubtype.DEFAULT, | 179 | subtype: photoAccessHelper.PhotoSubtype.DEFAULT, |
| @@ -157,7 +198,7 @@ export default class ImageGallerySaverPlusPlugin implements FlutterPlugin, Metho | |||
| 157 | } | 198 | } |
| 158 | let photoCreationConfigs: Array<photoAccessHelper.PhotoCreationConfig> = [ | 199 | let photoCreationConfigs: Array<photoAccessHelper.PhotoCreationConfig> = [ |
| 159 | { | 200 | { |
| 160 | - title: this.savefileName, | 201 | + title: this.sanitizeTitle(this.savefileName), |
| 161 | fileNameExtension: this.savefileType, | 202 | fileNameExtension: this.savefileType, |
| 162 | photoType: this.isImage ? photoAccessHelper.PhotoType.IMAGE : photoAccessHelper.PhotoType.VIDEO, | 203 | photoType: this.isImage ? photoAccessHelper.PhotoType.IMAGE : photoAccessHelper.PhotoType.VIDEO, |
| 163 | subtype: photoAccessHelper.PhotoSubtype.DEFAULT, | 204 | subtype: photoAccessHelper.PhotoSubtype.DEFAULT, |
| @@ -173,7 +214,7 @@ export default class ImageGallerySaverPlusPlugin implements FlutterPlugin, Metho | |||
| 173 | fileUri.getUriFromPath(uri) | 214 | fileUri.getUriFromPath(uri) |
| 174 | ]; | 215 | ]; |
| 175 | let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context); | 216 | let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context); |
| 176 | - // 拉起弹窗 | 217 | + // 拉起系统保存弹窗;用户取消时返回空数组 |
| 177 | let desFileUris: Array<string> = await phAccessHelper.showAssetsCreationDialog(srcFileUris, photoCreationConfigs); | 218 | let desFileUris: Array<string> = await phAccessHelper.showAssetsCreationDialog(srcFileUris, photoCreationConfigs); |
| 178 | if (desFileUris.length == 0) { | 219 | if (desFileUris.length == 0) { |
| 179 | return new ResultMessage(null, "user refuses permission", false); | 220 | return new ResultMessage(null, "user refuses permission", false); |
| @@ -207,8 +248,8 @@ export default class ImageGallerySaverPlusPlugin implements FlutterPlugin, Metho | |||
| 207 | let fileName: string[]; | 248 | let fileName: string[]; |
| 208 | let result = uri.split("/"); | 249 | let result = uri.split("/"); |
| 209 | fileName = result[result.length-1].split("."); | 250 | fileName = result[result.length-1].split("."); |
| 210 | - this.savefileName = fileName[0].toLowerCase(); | 251 | + this.savefileName = fileName[0] ? fileName[0].toLowerCase() : ''; |
| 211 | - this.savefileType = fileName[1].toLowerCase(); | 252 | + this.savefileType = fileName.length > 1 ? fileName[fileName.length - 1].toLowerCase() : ''; |
| 212 | if (name !== null && name !== undefined) { | 253 | if (name !== null && name !== undefined) { |
| 213 | fileName = name.split("."); | 254 | fileName = name.split("."); |
| 214 | switch (fileName.length) { | 255 | switch (fileName.length) { |
| @@ -233,4 +274,24 @@ export default class ImageGallerySaverPlusPlugin implements FlutterPlugin, Metho | |||
| 233 | } | 274 | } |
| 234 | return flag | 275 | return flag |
| 235 | } | 276 | } |
| 236 | -} | 277 | + |
| 278 | + // 鸿蒙相册 title 不能含 ':' 等字符,空 title 可能导致保存框不返回 | ||
| 279 | + private sanitizeTitle(name: string | null | undefined): string { | ||
| 280 | + let raw = ''; | ||
| 281 | + if (name !== null && name !== undefined && name.length > 0) { | ||
| 282 | + raw = name.split('.')[0]; | ||
| 283 | + } | ||
| 284 | + let cleaned = ''; | ||
| 285 | + for (let i = 0; i < raw.length; i++) { | ||
| 286 | + const ch = raw.charAt(i); | ||
| 287 | + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || | ||
| 288 | + (ch >= '0' && ch <= '9') || ch === '_' || ch === '-') { | ||
| 289 | + cleaned += ch; | ||
| 290 | + } | ||
| 291 | + } | ||
| 292 | + if (cleaned.length > 0) { | ||
| 293 | + return cleaned; | ||
| 294 | + } | ||
| 295 | + return `image_${Date.now()}`; | ||
| 296 | + } | ||
| 297 | +} | ||
【AI-Review】【一般】【基础代码问题】【资源使用问题】临时文件未清理导致 filesDir 持续膨胀
● 问题:saveImageToGallery 用
image_${Date.now()}.png生成唯一临时文件名(line 140),packToFile 写入数据,finally 仅 closeSync 关闭文件(line 169-171),随后 saveToPhoto 通过 fs.copyFile 复制到相册(saveToPhoto line 225-227),但复制完成后仅关闭 srcFile/desFile,未调用 fs.unlink 删除源文件。每次成功保存图片后,filesDir 下都会残留一个与原图等大的临时 png 文件。对比原代码固定使用imageLocal.png(每次覆盖)的模式,本次变更为唯一文件名后引入了累积问题。● 影响:一般。每次保存图片后会在 filesDir 留下与原图大小相当的临时文件(通常几 MB),频繁保存图片会导致 filesDir 持续膨胀,长期占用用户存储空间,且无清理机制。
● 建议:在 saveToPhoto 成功复制后清理临时文件,可在 line 182 调用 saveToPhoto 之后增加清理: const result = await this.saveToPhoto(filePath, photoCreationConfigs); try { fs.unlinkSync(filePath); } catch (_) {} return result; 或在 saveImageToGallery 的 finally 块中追加文件删除逻辑(注意 packToFile 失败分支也要清理空文件)。