Repository navigation
[path_provider_foundation] Avoid loading objective_c when answering a path - #13507
callmephil wants to merge 3 commits into
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
… path On iOS and macOS, the first call to any directory method blocked the calling isolate for several milliseconds while package:objective_c opened its native library. The directories and the main bundle identifier are now read through plain FFI calls into Foundation and CoreFoundation, so answering a path never loads that library. getContainerPath still uses package:objective_c.
87534e8 to
8188abd
Compare
There was a problem hiding this comment.
Code Review
This pull request replaces the use of package:objective_c with plain FFI calls to Foundation and CoreFoundation via a new FoundationPaths class to prevent blocking the calling isolate on the first directory method call. The review feedback suggests using CFCopySearchPathForDirectoriesInDomains and CFRelease instead of NSSearchPathForDirectoriesInDomains to avoid memory leaks on background isolates due to the lack of an active Objective-C autorelease pool, which would also allow removing the dependency on the Foundation framework entirely.
| /// Foundation, opened on first use rather than on import. | ||
| /// | ||
| /// Any app that uses AppKit or UIKit has already loaded it, so this returns | ||
| /// the handle of that copy. | ||
| final ffi.DynamicLibrary _foundation = ffi.DynamicLibrary.open( | ||
| '/System/Library/Frameworks/Foundation.framework/Foundation', | ||
| ); | ||
|
|
||
| /// CoreFoundation, opened on first use rather than on import, with the same | ||
| /// handle as the copy that Foundation already loaded. | ||
| final ffi.DynamicLibrary _coreFoundation = ffi.DynamicLibrary.open( | ||
| '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation', | ||
| ); | ||
|
|
||
| // Each lookup runs on first use and follows the types of the C headers: | ||
| // `CFIndex` is a signed long, `Boolean` an unsigned char and | ||
| // `CFStringEncoding` an unsigned int. | ||
|
|
||
| final _CFRef Function(int, int, bool) _searchPathForDirectories = _foundation | ||
| .lookupFunction< | ||
| _CFRef Function(ffi.UnsignedLong, ffi.UnsignedLong, ffi.Bool), | ||
| _CFRef Function(int, int, bool) | ||
| >('NSSearchPathForDirectoriesInDomains'); |
There was a problem hiding this comment.
By switching to CFCopySearchPathForDirectoriesInDomains, we no longer need to load the Foundation framework at all. We can load only CoreFoundation and lookup CFCopySearchPathForDirectoriesInDomains and CFRelease from it.
| /// Foundation, opened on first use rather than on import. | |
| /// | |
| /// Any app that uses AppKit or UIKit has already loaded it, so this returns | |
| /// the handle of that copy. | |
| final ffi.DynamicLibrary _foundation = ffi.DynamicLibrary.open( | |
| '/System/Library/Frameworks/Foundation.framework/Foundation', | |
| ); | |
| /// CoreFoundation, opened on first use rather than on import, with the same | |
| /// handle as the copy that Foundation already loaded. | |
| final ffi.DynamicLibrary _coreFoundation = ffi.DynamicLibrary.open( | |
| '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation', | |
| ); | |
| // Each lookup runs on first use and follows the types of the C headers: | |
| // `CFIndex` is a signed long, `Boolean` an unsigned char and | |
| // `CFStringEncoding` an unsigned int. | |
| final _CFRef Function(int, int, bool) _searchPathForDirectories = _foundation | |
| .lookupFunction< | |
| _CFRef Function(ffi.UnsignedLong, ffi.UnsignedLong, ffi.Bool), | |
| _CFRef Function(int, int, bool) | |
| >('NSSearchPathForDirectoriesInDomains'); | |
| /// CoreFoundation, opened on first use rather than on import. | |
| final ffi.DynamicLibrary _coreFoundation = ffi.DynamicLibrary.open( | |
| '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation', | |
| ); | |
| // Each lookup runs on first use and follows the types of the C headers: | |
| // `CFIndex` is a signed long, `Boolean` an unsigned char and | |
| // `CFStringEncoding` an unsigned int. | |
| final _CFRef Function(int, int, bool) _copySearchPathForDirectories = _coreFoundation | |
| .lookupFunction< | |
| _CFRef Function(ffi.UnsignedLong, ffi.UnsignedLong, ffi.Bool), | |
| _CFRef Function(int, int, bool) | |
| >('CFCopySearchPathForDirectoriesInDomains'); | |
| final void Function(_CFRef) _cfRelease = _coreFoundation | |
| .lookupFunction<ffi.Void Function(_CFRef), void Function(_CFRef)>('CFRelease'); |
There was a problem hiding this comment.
I can't find documentation for CFCopySearchPathForDirectoriesInDomains It could be a private API? If so App Store rule 2.5.1 only allows only public APIs to be used.
8517b32 to
661ed85
Compare
This PR doesn't actually address this cost, it just moves it to some other call; as more plugins adopt FFI-backed Pigeon, most apps will just hit this somewhere else (e.g., Have you filed an issue against |
…ndle ID On macOS, the bundle identifier was appended with '$path/$bundleIdentifier', which assumed Foundation's paths never end in a slash. That held in every test, but if one ever did, the path returned to apps would contain "//". NSURL.URLByAppendingPathComponent, which the join replaced, handled that case. The join now drops a trailing slash first, and a new test feeds in a path that ends in one.
…orelease pool NSSearchPathForDirectoriesInDomains returns an autoreleased array. On a background isolate, the thread's own autorelease pool drains only when the thread ends, so every folder lookup kept its array until then: about 20 MB per 100,000 lookups. Each lookup now pushes its own pool and pops it after copying the path out, using the runtime functions the compiler calls for an @autoreleasepool block.
661ed85 to
2c09d8c
Compare
Damn I haven't thought about looking there, Honestly the only reason I landed here is because of DenisovAV/flutter_edge_ai#756 They're un-related but I was trying to measure my startup time and I noticed some lag coming from path_provider after upgrading to 2.6 so I just treated this as a regression. Anyway I'm looking into the gemini report now then I'll take a second shovel and dig deeper, I'll let you know what I find. Thanks for the review! |
On iOS and macOS, the first call to any directory method blocks the calling isolate for several milliseconds: 6.4 to 12.9 ms on a Mac and 9.0 to 11.8 ms on an iPhone 11, in five profile-mode runs each. The cost is the first use of
package:objective_c, which opens its native library. The Foundation call itself takes about 20 µs.This change reads the folder and the main bundle identifier with plain FFI calls into Foundation and CoreFoundation.
NSArrayandNSStringare toll-free bridged toCFArrayandCFString, so the CoreFoundation functions can read what Foundation returns, and answering a path never loadspackage:objective_c.getContainerPathstill uses it, and loads it only when called.FoundationPathsinlib/src/foundation_paths.dartreplaces the injectableFoundationFFIof the constructor:ffiLibbecomesfoundationPaths.NSURL.URLByAppendingPathComponent. As with that method, the result never contains//: the join drops a trailing slash from Foundation's path first.FoundationPathsinstead of the raw Foundation call, and the Dart unit tests no longer need a fakeFoundationFFI. New integration tests compareFoundationPathswith the Objective-C answers for five directories and the bundle identifier, and one checks the join when Foundation's path ends in a slash.FoundationFFIand its generatedNSSearchPathForDirectoriesInDomainsbinding are now used only by those tests.Measured with a probe that records the longest gap between ticks of a 1 ms timer on the UI isolate, in profile mode with Flutter 3.47.8, five runs each, on a MacBook Pro M3 Max (macOS 26.6.2) and an iPhone 11 (iOS 27.0.1):
*The 4.1 ms was the first launch after a fresh build; the other four runs were 1.7 to 2.1 ms.
The integration tests pass on the Mac, 31 of 31, and on the iPhone, 30 of 31:
getContainerDirectorywas not run there, because it needs an app group my signing team could not add. Not tested in a release build.Fixes flutter/flutter#195697
Pre-Review Checklist
[shared_preferences]///).If you need help, consider asking for advice on the #hackers-new channel on Discord.
Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the
gemini-code-assistbot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.Footnotes
Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. ↩ ↩2