diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 9b32af241032..f846774ffe0a 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.4+10 + +* iOS performance improvement by moving file writing from the main queue to a background IO queue. + ## 0.9.4+9 * iOS performance improvement by moving sample buffer handling from the main queue to a background session queue. diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj index 65a6bbda3261..ac39de2e3f84 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 50; objects = { /* Begin PBXBuildFile section */ @@ -22,6 +22,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; E01EE4A82799F3A5008C1950 /* QueueHelperTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E01EE4A72799F3A5008C1950 /* QueueHelperTests.m */; }; E032F250279F5E94009E9028 /* CameraCaptureSessionQueueRaceConditionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E032F24F279F5E94009E9028 /* CameraCaptureSessionQueueRaceConditionTests.m */; }; + E04F108627A87CA600573D0C /* FLTSavePhotoDelegateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */; }; E0C6E2002770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */; }; E0C6E2012770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */; }; E0C6E2022770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FF2770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m */; }; @@ -83,6 +84,7 @@ A24F9E418BA48BCC7409B117 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; E01EE4A72799F3A5008C1950 /* QueueHelperTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QueueHelperTests.m; sourceTree = ""; }; E032F24F279F5E94009E9028 /* CameraCaptureSessionQueueRaceConditionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CameraCaptureSessionQueueRaceConditionTests.m; sourceTree = ""; }; + E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTSavePhotoDelegateTests.m; sourceTree = ""; }; E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeMethodChannelTests.m; sourceTree = ""; }; E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeTextureRegistryTests.m; sourceTree = ""; }; E0C6E1FF2770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeEventChannelTests.m; sourceTree = ""; }; @@ -125,6 +127,7 @@ E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */, E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */, E0F95E4327A36B9200699390 /* SampleBufferQueueTests.m */, + E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */, E01EE4A72799F3A5008C1950 /* QueueHelperTests.m */, E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */, F6EE622E2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m */, @@ -399,6 +402,7 @@ E0F95E3D27A32AB900699390 /* CameraPropertiesTests.m in Sources */, 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */, E487C86026D686A10034AC92 /* CameraPreviewPauseTests.m in Sources */, + E04F108627A87CA600573D0C /* FLTSavePhotoDelegateTests.m in Sources */, F6EE622F2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m in Sources */, 334733EA2668111C00DCC49E /* CameraOrientationTests.m in Sources */, E032F250279F5E94009E9028 /* CameraCaptureSessionQueueRaceConditionTests.m in Sources */, diff --git a/packages/camera/camera/example/ios/RunnerTests/FLTSavePhotoDelegateTests.m b/packages/camera/camera/example/ios/RunnerTests/FLTSavePhotoDelegateTests.m new file mode 100644 index 000000000000..b6ea84da449c --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/FLTSavePhotoDelegateTests.m @@ -0,0 +1,133 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import camera; +@import camera.Test; +@import AVFoundation; +@import XCTest; +#import + +@interface FLTSavePhotoDelegateTests : XCTestCase + +@end + +@implementation FLTSavePhotoDelegateTests + +- (void)testHandlePhotoCaptureResult_mustSendErrorIfFailedToCapture { + NSError *error = [NSError errorWithDomain:@"test" code:0 userInfo:nil]; + dispatch_queue_t ioQueue = dispatch_queue_create("test", NULL); + id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]); + FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc] initWithPath:@"test" + result:mockResult + ioQueue:ioQueue]; + + [delegate handlePhotoCaptureResultWithError:error + photoDataProvider:^NSData * { + return nil; + }]; + OCMVerify([mockResult sendError:error]); +} + +- (void)testHandlePhotoCaptureResult_mustSendErrorIfFailedToWrite { + XCTestExpectation *resultExpectation = + [self expectationWithDescription:@"Must send IOError to the result if failed to write file."]; + dispatch_queue_t ioQueue = dispatch_queue_create("test", NULL); + id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]); + + NSError *ioError = [NSError errorWithDomain:@"IOError" + code:0 + userInfo:@{NSLocalizedDescriptionKey : @"Localized IO Error"}]; + + OCMStub([mockResult sendErrorWithCode:@"IOError" + message:@"Unable to write file" + details:ioError.localizedDescription]) + .andDo(^(NSInvocation *invocation) { + [resultExpectation fulfill]; + }); + FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc] initWithPath:@"test" + result:mockResult + ioQueue:ioQueue]; + + // We can't use OCMClassMock for NSData because some XCTest APIs uses NSData (e.g. + // `XCTRunnerIDESession::logDebugMessage:`) on a private queue. + id mockData = OCMPartialMock([NSData data]); + OCMStub([mockData writeToFile:OCMOCK_ANY + options:NSDataWritingAtomic + error:[OCMArg setTo:ioError]]) + .andReturn(NO); + [delegate handlePhotoCaptureResultWithError:nil + photoDataProvider:^NSData * { + return mockData; + }]; + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testHandlePhotoCaptureResult_mustSendSuccessIfSuccessToWrite { + XCTestExpectation *resultExpectation = [self + expectationWithDescription:@"Must send file path to the result if success to write file."]; + + dispatch_queue_t ioQueue = dispatch_queue_create("test", NULL); + id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]); + FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc] initWithPath:@"test" + result:mockResult + ioQueue:ioQueue]; + OCMStub([mockResult sendSuccessWithData:delegate.path]).andDo(^(NSInvocation *invocation) { + [resultExpectation fulfill]; + }); + + // We can't use OCMClassMock for NSData because some XCTest APIs uses NSData (e.g. + // `XCTRunnerIDESession::logDebugMessage:`) on a private queue. + id mockData = OCMPartialMock([NSData data]); + OCMStub([mockData writeToFile:OCMOCK_ANY options:NSDataWritingAtomic error:[OCMArg setTo:nil]]) + .andReturn(YES); + + [delegate handlePhotoCaptureResultWithError:nil + photoDataProvider:^NSData * { + return mockData; + }]; + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testHandlePhotoCaptureResult_bothProvideDataAndSaveFileMustRunOnIOQueue { + XCTestExpectation *dataProviderQueueExpectation = + [self expectationWithDescription:@"Data provider must run on io queue."]; + XCTestExpectation *writeFileQueueExpectation = + [self expectationWithDescription:@"File writing must run on io queue"]; + XCTestExpectation *resultExpectation = [self + expectationWithDescription:@"Must send file path to the result if success to write file."]; + + dispatch_queue_t ioQueue = dispatch_queue_create("test", NULL); + const char *ioQueueSpecific = "io_queue_specific"; + dispatch_queue_set_specific(ioQueue, ioQueueSpecific, (void *)ioQueueSpecific, NULL); + id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]); + OCMStub([mockResult sendSuccessWithData:OCMOCK_ANY]).andDo(^(NSInvocation *invocation) { + [resultExpectation fulfill]; + }); + + // We can't use OCMClassMock for NSData because some XCTest APIs uses NSData (e.g. + // `XCTRunnerIDESession::logDebugMessage:`) on a private queue. + id mockData = OCMPartialMock([NSData data]); + OCMStub([mockData writeToFile:OCMOCK_ANY options:NSDataWritingAtomic error:[OCMArg setTo:nil]]) + .andDo(^(NSInvocation *invocation) { + if (dispatch_get_specific(ioQueueSpecific)) { + [writeFileQueueExpectation fulfill]; + } + }) + .andReturn(YES); + + FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc] initWithPath:@"test" + result:mockResult + ioQueue:ioQueue]; + [delegate handlePhotoCaptureResultWithError:nil + photoDataProvider:^NSData * { + if (dispatch_get_specific(ioQueueSpecific)) { + [dataProviderQueueExpectation fulfill]; + } + return mockData; + }]; + + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +@end diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.modulemap b/packages/camera/camera/ios/Classes/CameraPlugin.modulemap index a695728fc87a..529c6580e908 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.modulemap +++ b/packages/camera/camera/ios/Classes/CameraPlugin.modulemap @@ -9,5 +9,11 @@ framework module camera { header "CameraProperties.h" header "FLTCam.h" header "FLTCam_Test.h" + header "FLTSavePhotoDelegate_Test.h" + header "FLTThreadSafeEventChannel.h" + header "FLTThreadSafeFlutterResult.h" + header "FLTThreadSafeMethodChannel.h" + header "FLTThreadSafeTextureRegistry.h" + header "QueueHelper.h" } } diff --git a/packages/camera/camera/ios/Classes/FLTCam.m b/packages/camera/camera/ios/Classes/FLTCam.m index 82ac6714d689..94f985066675 100644 --- a/packages/camera/camera/ios/Classes/FLTCam.m +++ b/packages/camera/camera/ios/Classes/FLTCam.m @@ -4,6 +4,7 @@ #import "FLTCam.h" #import "FLTCam_Test.h" +#import "FLTSavePhotoDelegate.h" @import CoreMotion; #import @@ -41,71 +42,6 @@ - (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments } @end -@interface FLTSavePhotoDelegate : NSObject -@property(readonly, nonatomic) NSString *path; -@property(readonly, nonatomic) FLTThreadSafeFlutterResult *result; -@end - -@implementation FLTSavePhotoDelegate { - /// Used to keep the delegate alive until didFinishProcessingPhotoSampleBuffer. - FLTSavePhotoDelegate *selfReference; -} - -- initWithPath:(NSString *)path result:(FLTThreadSafeFlutterResult *)result { - self = [super init]; - NSAssert(self, @"super init cannot be nil"); - _path = path; - selfReference = self; - _result = result; - return self; -} - -- (void)captureOutput:(AVCapturePhotoOutput *)output - didFinishProcessingPhotoSampleBuffer:(CMSampleBufferRef)photoSampleBuffer - previewPhotoSampleBuffer:(CMSampleBufferRef)previewPhotoSampleBuffer - resolvedSettings:(AVCaptureResolvedPhotoSettings *)resolvedSettings - bracketSettings:(AVCaptureBracketedStillImageSettings *)bracketSettings - error:(NSError *)error API_AVAILABLE(ios(10)) { - selfReference = nil; - if (error) { - [_result sendError:error]; - return; - } - - NSData *data = [AVCapturePhotoOutput - JPEGPhotoDataRepresentationForJPEGSampleBuffer:photoSampleBuffer - previewPhotoSampleBuffer:previewPhotoSampleBuffer]; - - // TODO(sigurdm): Consider writing file asynchronously. - bool success = [data writeToFile:_path atomically:YES]; - - if (!success) { - [_result sendErrorWithCode:@"IOError" message:@"Unable to write file" details:nil]; - return; - } - [_result sendSuccessWithData:_path]; -} - -- (void)captureOutput:(AVCapturePhotoOutput *)output - didFinishProcessingPhoto:(AVCapturePhoto *)photo - error:(NSError *)error API_AVAILABLE(ios(11.0)) { - selfReference = nil; - if (error) { - [_result sendError:error]; - return; - } - - NSData *photoData = [photo fileDataRepresentation]; - - bool success = [photoData writeToFile:_path atomically:YES]; - if (!success) { - [_result sendErrorWithCode:@"IOError" message:@"Unable to write file" details:nil]; - return; - } - [_result sendSuccessWithData:_path]; -} -@end - @interface FLTCam () @@ -138,8 +74,11 @@ @interface FLTCam () +/// The file path for the captured photo. +@property(readonly, nonatomic) NSString *path; +/// The thread safe flutter result wrapper to report the result. +@property(readonly, nonatomic) FLTThreadSafeFlutterResult *result; +/// The queue on which captured photos are wrote to disk. +@property(strong, nonatomic) dispatch_queue_t ioQueue; +/// Used to keep the delegate alive until didFinishProcessingPhotoSampleBuffer. +@property(strong, nonatomic, nullable) FLTSavePhotoDelegate *selfReference; + +/** + * Initialize a photo capture delegate. + * @param path the path for captured photo file. + * @param result the thread safe flutter result wrapper to report the result. + * @param ioQueue the queue on which captured photos are wrote to disk. + */ +- (instancetype)initWithPath:(NSString *)path + result:(FLTThreadSafeFlutterResult *)result + ioQueue:(dispatch_queue_t)ioQueue; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.m b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.m new file mode 100644 index 000000000000..8dadfec7fecd --- /dev/null +++ b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.m @@ -0,0 +1,65 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTSavePhotoDelegate.h" + +@implementation FLTSavePhotoDelegate + +- (instancetype)initWithPath:(NSString *)path + result:(FLTThreadSafeFlutterResult *)result + ioQueue:(dispatch_queue_t)ioQueue { + self = [super init]; + NSAssert(self, @"super init cannot be nil"); + _path = path; + _selfReference = self; + _result = result; + _ioQueue = ioQueue; + return self; +} + +- (void)handlePhotoCaptureResultWithError:(NSError *)error + photoDataProvider:(NSData * (^)(void))photoDataProvider { + self.selfReference = nil; + if (error) { + [self.result sendError:error]; + return; + } + dispatch_async(self.ioQueue, ^{ + NSData *data = photoDataProvider(); + NSError *ioError; + if ([data writeToFile:self.path options:NSDataWritingAtomic error:&ioError]) { + [self.result sendSuccessWithData:self.path]; + } else { + [self.result sendErrorWithCode:@"IOError" + message:@"Unable to write file" + details:ioError.localizedDescription]; + } + }); +} + +- (void)captureOutput:(AVCapturePhotoOutput *)output + didFinishProcessingPhotoSampleBuffer:(CMSampleBufferRef)photoSampleBuffer + previewPhotoSampleBuffer:(CMSampleBufferRef)previewPhotoSampleBuffer + resolvedSettings:(AVCaptureResolvedPhotoSettings *)resolvedSettings + bracketSettings:(AVCaptureBracketedStillImageSettings *)bracketSettings + error:(NSError *)error API_AVAILABLE(ios(10)) { + [self handlePhotoCaptureResultWithError:error + photoDataProvider:^NSData * { + return [AVCapturePhotoOutput + JPEGPhotoDataRepresentationForJPEGSampleBuffer:photoSampleBuffer + previewPhotoSampleBuffer: + previewPhotoSampleBuffer]; + }]; +} + +- (void)captureOutput:(AVCapturePhotoOutput *)output + didFinishProcessingPhoto:(AVCapturePhoto *)photo + error:(NSError *)error API_AVAILABLE(ios(11.0)) { + [self handlePhotoCaptureResultWithError:error + photoDataProvider:^NSData * { + return [photo fileDataRepresentation]; + }]; +} + +@end diff --git a/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate_Test.h b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate_Test.h new file mode 100644 index 000000000000..c0b77c7bac83 --- /dev/null +++ b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate_Test.h @@ -0,0 +1,17 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTSavePhotoDelegate.h" + +/** + API exposed for unit tests. + */ +@interface FLTSavePhotoDelegate () + +/// Handler to write captured photo data into a file. +/// @param error the capture error. +/// @param photoDataProvider a closure that provides photo data. +- (void)handlePhotoCaptureResultWithError:(NSError *)error + photoDataProvider:(NSData * (^)(void))photoDataProvider; +@end diff --git a/packages/camera/camera/ios/Classes/camera-umbrella.h b/packages/camera/camera/ios/Classes/camera-umbrella.h index 428b125d3a43..5c39401e6261 100644 --- a/packages/camera/camera/ios/Classes/camera-umbrella.h +++ b/packages/camera/camera/ios/Classes/camera-umbrella.h @@ -4,11 +4,6 @@ #import #import -#import -#import -#import -#import -#import FOUNDATION_EXPORT double cameraVersionNumber; FOUNDATION_EXPORT const unsigned char cameraVersionString[]; diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 0f1a0f049d76..4516e5593023 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for controlling the camera. Supports previewing Dart. repository: https://github.com/flutter/plugins/tree/main/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.4+9 +version: 0.9.4+10 environment: sdk: ">=2.14.0 <3.0.0"