Skip to content

[path_provider_foundation] Avoid loading objective_c when answering a path - #13507

Open
callmephil wants to merge 3 commits into
flutter:mainfrom
callmephil:path-provider-foundation-first-call
Open

callmephil wants to merge 3 commits into
flutter:mainfrom
callmephil:path-provider-foundation-first-call

Conversation

@callmephil

@callmephil callmephil commented Oct 30, 2026 •

Copy link
Copy Markdown

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. NSArray and NSString are toll-free bridged to CFArray and CFString, so the CoreFoundation functions can read what Foundation returns, and answering a path never loads package:objective_c. getContainerPath still uses it, and loads it only when called.

  • New FoundationPaths in lib/src/foundation_paths.dart replaces the injectable FoundationFFI of the constructor: ffiLib becomes foundationPaths.
  • Each folder lookup runs inside an autorelease pool, so the array Foundation returns is freed before the lookup returns, on any isolate. That includes a background isolate, whose thread's own pool drains only when the thread ends. In a test of 300,000 lookups on a background isolate, memory stayed flat.
  • On macOS, the bundle identifier is appended with a plain string join instead of NSURL.URLByAppendingPathComponent. As with that method, the result never contains //: the join drops a trailing slash from Foundation's path first.
  • Tests: the integration tests' unit group now stubs FoundationPaths instead of the raw Foundation call, and the Dart unit tests no longer need a fake FoundationFFI. New integration tests compare FoundationPaths with the Objective-C answers for five directories and the bundle identifier, and one checks the join when Foundation's path ends in a slash.
  • FoundationFFI and its generated NSSearchPathForDirectoriesInDomains binding 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):

First call Mac iPhone 11
2.6.0 6.4 to 12.9 ms 9.0 to 11.8 ms
This change 0.3 to 0.6 ms 1.7 to 4.1 ms*
2.5.1 (platform channel) 0.4 to 0.5 ms 1.6 to 3.5 ms

*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: getContainerDirectory was 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

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-assist bot 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

  1. 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

@google-cla

google-cla Bot commented Oct 30, 2026

Copy link
Copy Markdown

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.
@callmephil
callmephil force-pushed the path-provider-foundation-first-call branch from 87534e8 to 8188abd Compare October 30, 2026 22:05
@callmephil
callmephil marked this pull request as ready for review October 30, 2026 22:48

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +101 to +123
/// 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');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
/// 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');

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@callmephil
callmephil force-pushed the path-provider-foundation-first-call branch 2 times, most recently from 8517b32 to 661ed85 Compare October 30, 2026 23:06
@stuartmorgan

Copy link
Copy Markdown
Contributor

The cost is the first use of package:objective_c, which opens its native library.

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., shared_preferences). Making the code more complicated in this plugin just to shift the cost elsewhere in the same application doesn't seem like a good trade-off.

Have you filed an issue against objective_c to investigate ways to reduce first-time run costs?

…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.
@callmephil
callmephil force-pushed the path-provider-foundation-first-call branch from 661ed85 to 2c09d8c Compare October 31, 2026 00:01
@callmephil

Copy link
Copy Markdown
Author

The cost is the first use of package:objective_c, which opens its native library.

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., shared_preferences). Making the code more complicated in this plugin just to shift the cost elsewhere in the same application doesn't seem like a good trade-off.

Have you filed an issue against objective_c to investigate ways to reduce first-time run costs?

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[path_provider] iOS and macOS: the first getApplicationSupportDirectory() call blocks the UI isolate for 6 to 13 ms since path_provider_foundation 2.6.0

2 participants