-
Notifications
You must be signed in to change notification settings - Fork 9.8k
[shared_preferences] upgraded ios to using pigeon #4732
[shared_preferences] upgraded ios to using pigeon #4732
Conversation
c0940b6
to
515732a
Compare
'Bool': (String key, Object value) { | ||
return _api.setBool(key, value as bool); | ||
}, | ||
'Double': (String key, Object value) { | ||
return _api.setDouble(key, value as double); | ||
}, | ||
'Int': (String key, Object value) { | ||
return _api.setInt(key, value as int); | ||
}, | ||
'String': (String key, Object value) { | ||
return _api.setString(key, value as String); | ||
}, | ||
'StringList': (String key, Object value) { | ||
return _api.setStringList(key, value as List<String?>); | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately the interface has strings built into it, so I used them as the source of truth.
f69ab4b
to
10569d7
Compare
@stuartmorgan I decided to split up iOS and Android implementations so we can touch base on a smaller codebase before jumping off. |
Yes, we should really consider adding new methods to the platform interface and deprecating the string-based one. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
High level notes:
- I left some comments on the API itself; if we're making a new platform-specific Dart/native API boundary from scratch that's iOS-specific, there's no reason we need to recreate the old, platform-agnostic decisions in the Pigeon version.
- Please file issues for the pigeon notes so we can track them. They aren't blockers here, but I don't want to lose them.
} | ||
|
||
void SharedPreferencesApiSetup(id<FlutterBinaryMessenger> binaryMessenger, | ||
NSObject<SharedPreferencesApi> *api) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pigeon note: ObjC naming would be SharedPreferencesAPI.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think Pigeon should mangle the names? That probably isn't possible. We could provide an option to override the generated name if they want something different that what is provided in the input.
} else { | ||
[channel setMessageHandler:nil]; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pigeon note: Couldn't we reduce the amount of generated boilerplate code by making this whole block a helper function, and only generate the block and name each time? (And the assertion I guess).
static void SomeHelper(
id<FlutterBinaryMessenger> binaryMessenger,
NSObject<SharedPreferencesApi *api,
NSString *channelName,
<insert correct block syntax here> methodHandler) {
FlutterBasicMessageChannel *channel = [FlutterBasicMessageChannel
messageChannelWithName:channelName
binaryMessenger:binaryMessenger
codec:SharedPreferencesApiGetCodec()];
if (api) {
[channel setMessageHandler:messageHandler];
} else {
[channel setMessageHandler:nil];
}
}
Then each code block here becomes just:
NSCAssert(!api || [api respondsToSelector:@selector(removeKey:error:)],
@"SharedPreferencesApi api (%@) doesn't respond to @selector(removeKey:error:)",
api);
SomeHelper(binaryMessenger, api, @"dev.flutter.pigeon.SharedPreferencesApi.remove",
^(id _Nullable message, FlutterReply callback) {
NSArray *args = message;
NSString *arg_key = args[0];
FlutterError *error;
NSNumber *output = [api removeKey:arg_key error:&error];
callback(wrapResult(output, error));
});
It wouldn't even need the scoping block.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, that could be something we look into. It isn't boilerplate since it is generated and can be treated as a black box by the user. Boilerplate to me means code a user has to manage that has semantic meaning that is small compared to its verbosity.
- (FlutterStandardReader *)readerWithData:(NSData *)data { | ||
return [[SharedPreferencesApiCodecReader alloc] initWithData:data]; | ||
} | ||
@end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pigeon note: It would be nice to optimize the generator to not create these at all when there are no custom types.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filed issue: flutter/flutter#97845
@@ -3,69 +3,10 @@ | |||
// found in the LICENSE file. | |||
|
|||
#import "FLTSharedPreferencesPlugin.h" | |||
#import "messages.g.h" | |||
|
|||
static NSString *const CHANNEL_NAME = @"plugins.flutter.io/shared_preferences"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is dead code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
[[NSUserDefaults standardUserDefaults] setValue:value forKey:key]; | ||
return @YES; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pigeon note: I think there's a potential red flag here that we've created over two hundred lines of generated code to make an 80-line implementation file 10 lines longer.
Strong typing is definitely good, but the tradeoff here is getting to be somewhat questionable. I think it's worth thinking about ways to make pigeon feel lighter weight for simple plugins.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think lines of code is the correct way to measure the change because of objc formatting and the need to distinguish between compile time verified lines and runtime verified lines. Here's instead how the changes should be measured:
- Every method went from ~6 operations to ~2.
- Every method removed 2 casts
- Conformance to the API is enforced at compile time
- Error handling has been made available to the objc handlers
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another thing to note is that xcode generated most of this code so it didn't feel heavy weight when writing it. Once you implement a protocol xcode offers to fill it in.
SharedPreferencesIos.registerWith(); | ||
expect( | ||
SharedPreferencesStorePlatform.instance, isA<SharedPreferencesIos>()); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The actual API passthroughs should be tested as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
bool setInt(String key, int value); | ||
bool setString(String key, String value); | ||
bool setStringList(String key, List<String> value); | ||
bool clear(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
None of these messages ever returns anything other than YES
, so they should all just be void.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think so, the platform_interface states that a bool should be returned from these methods. It should be the host platform that decides the value, not the glue code between the platform_interface and the host code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think so, the platform_interface states that a bool should be returned from these methods.
I'm not suggesting that the implementation of the platform interface—which is Dart code—violate that. This structure is not the implementation of the platform interface, it's a completely internal API boundary.
It should be the host platform that decides the value, not the glue code between the platform_interface and the host code.
shared_preferences_ios is "the host platform". It's all iOS code. Which parts of that iOS code are written in Dart and which parts in ObjC are up to us, as is defining the API between those layers.
There is no reason to put useless return value on an internal API boundary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no reason to put useless return value on an internal API boundary.
Here's how you are looking at it:
(dart_platform_interface)->(dart_host_implementation)->(objc_host_implementation)
Here's how I think you should be thinking about it:
(dart_platform_interface)->(dart_glue)->(host_implementation)
It makes maintaining much easier since logic for implementing dart_platform_interface
isn't spread across multiple files.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no reason to put useless return value on an internal API boundary.
Here's how you are looking at it:
(dart_platform_interface)->(dart_host_implementation)->(objc_host_implementation)
Not really, no.
I look it at as:
Dart platform interface
Adapter code
System API
More specifically:
Dart platform interface
Adapter code (Dart)
Method channel/pigeon API
Adapter code (Native language)
System API
Here's how I think you should be thinking about it:
(dart_platform_interface)->(dart_glue)->(host_implementation)
To the extent that I think we should have an abstract goal of minimizing the amount of adapter code on one side of the language boundary, I think it should be the native side, favoring writing adapter code in Dart, for the reasons laid out in https://docs.flutter.dev/go/platform-channels-in-federated-plugins (search for "Allows moving adapter code from native code to Dart")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well it doesn't help that what those return values mean is never defined, so I can't even tell you if the values are correct or if they need to be generated from the native code. Imagine those return values actually meant something, they would have to be forwarded from the host platform to the plugin. It seems a bit silly to make a special case about where the return values come from in the case where return values mean nothing. I think it's just setting up the wrong example.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well it doesn't help that what those return values mean is never defined
On Android the setters can fail, and the return values is whether setting succeeded.
A PR fixing the docs to say that sounds like a good idea.
so I can't even tell you if the values are correct
Setting a value can't fail on iOS, so unconditionally returning true is the correct implementation on iOS.
or if they need to be generated from the native code.
We know that they don't, because we know what the APIs on iOS are.
Imagine those return values actually meant something, they would have to be forwarded from the host platform to the plugin.
If there were meaningful return values that we needed from the native side on iOS, I wouldn't have made that review comment in the first place.
It seems a bit silly to make a special case about where the return values come from in the case where return values mean nothing.
There is, and will only ever be, one implementation of the API you are defining with this pigeon file. When there is only one case, "special case" isn't meaningful.
I think it's just setting up the wrong example.
This isn't an example. It's a specific, concrete case.
bool setDouble(String key, double value); | ||
bool setInt(String key, int value); | ||
bool setString(String key, String value); | ||
bool setStringList(String key, List<String> value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The methods that just use setValueForKey
can all be collapsed together.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's better to match the Dart interface as closely as possible instead instead of trying to be smart in the glue code (as per other comment).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And specifically, my point here is not just that you are putting logic in the Dart code there, but the purpose of that logic is to maximally fragment the calls by value type. You've created an API layer that not only has more calls than the underlying host API (which is what I suggest matching), but far more than the Dart interface. Your argument against reducing the number from five setters to three is that you want to better match the side that only has one...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, I'd say that's matching the Dart interface. The interface is weird in that case because it's defined in the comments and in the code not just in code because Dart lacks the ability to express it in code. There is no computation or branching in the dart file, just dispatching.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know it's late there, we can hash out the last mile of this conversation on Monday, over vc if we have to.
@override | ||
Future<Map<String, Object>> getAll() async { | ||
final Map<String?, Object?> result = await _api.getAll(); | ||
return result.cast<String, Object>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We definitely need non-null generics; this is ugly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (setter != null) { | ||
return setter(key, value); | ||
} else { | ||
return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You've changed the semantics; this used to be a platform error, and now it just returns false. I think the error is a much better option here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
81a81e0
to
edb08fd
Compare
Talked with stuart offline. The thesis is that we should be moving as much as logic as possible to the Dart layer and making the pigeon files match host API's as closely as possible, maybe opening up the door for some tool that will make host apis accessible by Dart more directly. I'll rework this PR to make the pigeon interface look more like that. |
- started throwing an exception for invalid types - removed dead code - added unit tests
19fec11
to
bb7285f
Compare
bb7285f
to
e9ddd40
Compare
@stuartmorgan I made the changes we discussed last week. This now matches our goal of removing logic from the host platform and putting it into Dart, also making the pigeon abstraction match the host api. Give it a look to make sure this is the path we want to take. We can't fully migrate yet if we want to go this route since it requires returning a nullable map for If this is where we want to be with our plugins I think we should divide the work up into 2 different PR since I ended up having to migrate to Pigeon and also migrate the abstraction to be closer to the host platform in the same PR. Doing both things in the same PR is a bit dicey. In the first PR, the migration to Pigeon should attempt to minimize changes to the host platforms code, then in the second PR can migrate code from the host platform to Dart. |
Actually we can move forward now if we just make that return value nonnull. It is a slight deviation from the API, but I made that change. PTAL. |
59fe511
to
48993a6
Compare
48993a6
to
502a358
Compare
Future<bool> clear() async { | ||
final Map<String, Object> all = await getAll(); | ||
for (final String key in all.keys) { | ||
await _api.remove(key); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is incredibly slow and inefficient relative to doing it on the native side. Moving things to Dart is a general goal, but we shouldn't do it at the expense of the quality of the plugin.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, I wanted to take the code to the logical extreme of what we are saying we wanted to see how it looked. If our goal is to move logic from host to dart that's often going to incur a performance cost.
I moved it back to the native side.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fortunately logical extremes are not the only options available to us. As with all code, we can balance performance against other design goals, favoring the general goal in the general case, and adjusting for performance in specific cases where it will obviously have a significant impact (like here, where moving a few lines of code puts a potentially large number of cross-boundary calls in a tight loop).
final Map<String, Object> result = | ||
(await _api.getAll()).cast<String, Object>(); | ||
result | ||
.removeWhere((String key, Object value) => !key.startsWith('flutter.')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The filtering logic has to stay on the native side. It's there in case people want to store non-Flutter prefs (e.g., in add-to-app) and there's no guarantee that the things with other keys can be handled by our codec since we didn't write them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. That one didn't occur to me, but raising the idea it would be nice if we did lazy serialization / deserialization. That could help with some of the performance issues we are seeing.
/// The macOS implementation of [SharedPreferencesStorePlatform]. | ||
/// | ||
/// This class implements the `package:shared_preferences` functionality for iOS. | ||
class SharedPreferencesIOS extends SharedPreferencesStorePlatform { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Renaming the class is a breaking change.
It's also still not actually clear to me what the correct capitalization is. The relevant Dart rule says that two-letter acronyms are capitalized in Dart. "i" isn't part of the acronym; the acronym itself, OS, is two letters. So there are two possible interpretations:
- It's a single letter ("i"), which is capitalized because it's the first letter, followed by a two-letter acronym, which is also capitalized, giving
IOS
. - We pretend it's a normal three-letter word (or a three-letter acronym-like thing?), giving
Ios
.
Dart appears to use the former (ref), and I only just realized that that there's an explicit mention in the Flutter style guide saying it should be the latter (although that section is itself somewhat wrong since it says Dart rules would give macOs
, which isn't correct). Given the existing inconsistency in the ecosystem, I haven't caught one vs. the other in reviews.
I'll make sure to apply the Flutter rule going forward now that I'm aware of it, but it's not worth a breaking change here to fix.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, done.
value:(NSNumber *)value | ||
error:(FlutterError *_Nullable *_Nonnull)error; | ||
- (void)setValueKey:(NSString *)key value:(id)value error:(FlutterError *_Nullable *_Nonnull)error; | ||
- (nullable NSDictionary<NSString *, id> *)getAllWithError:(FlutterError *_Nullable *_Nonnull)error; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is Pigeon annotating this as nullable? It's non-nullable in the Dart specification; that seems like a significant Pigeon bug. (Or is this stale?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not stale. It was probably done because of error handling. If there is an error you don't want people to artificially return some value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, reading through the code, that's what it's for. If you return nil and set an error, it will throw a PlatformException
on the dart side. That's what we want. If you return nil and don't set an error, you'll get an exception when it is cast to a non-null type before returning. I think that's what we want as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That means that it's currently impossible to distinguish in the context of the native code between a nullable and a non-nullable return value, which is definitely not great. We can't do anything compiler-enforceable I guess, which is unfortunate, but it seems like we should at least have pigeon put a comment on methods like this that says:
/// This may only return nil if 'error' is set.
so that there's clear information within the context of the generated native code what the required behavior is.
If you return nil and don't set an error, you'll get an exception when it is cast to a non-null type before returning. I think that's what we want as well.
It looks like that's only on the Dart side. Shouldn't we be validating in ObjC code that either error
or the return value is non-nil, in the case of non-nullable return values? Otherwise mistakes will look like they are on the Dart side rather than in the native implementation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, those are improvements we can do when we implement nullable return types. I'll add a note to that issue: flutter/flutter#98452 (comment)
Shouldn't we be validating in ObjC code that either error or the return value is non-nil, in the case of non-nullable return values? Otherwise mistakes will look like they are on the Dart side rather than in the native implementation.
It doesn't necessarily have to happen on the objc side. The end result will look the same to a user, an exception on the Dart side. I think generating a PlatformException
about a null value instead of a null value exception will be more clear though.
it seems like we should at least have pigeon put a comment on methods like this that says:
Yea, it's a good idea. I don't know how helpful it will be in practice because it won't show up where people are actually implementing the logic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The end result will look the same to a user, an exception on the Dart side.
Right, I wasn't thinking of the end user, but the plugin developer(s). Especially if multiple platforms share method channels (which is still overwhelmingly the common case in the ecosystem). If the exception is in the Dart code, it points someone investigating in the wrong direction; probably not a huge effect, but anything that makes it harder for people to root-cause bugs isn't ideal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM with nits.
UserDefaultsApiSetup(registrar.messenger, plugin); | ||
} | ||
|
||
- (nullable NSDictionary<NSString *, id> *)getAllWithError: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add a:
// Must not return nil unless "error" is set.
here, so that we have an explicit flag at the implementation point.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
@@ -24,3 +24,5 @@ dependencies: | |||
dev_dependencies: | |||
flutter_test: | |||
sdk: flutter | |||
pedantic: ^1.10.0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pedantic
should not be re-added.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
}); | ||
test('setValueMap', () { | ||
expect(() async { | ||
await plugin.setValue('flutter.Map', 'key', <String, String>{}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The flutter.
prefix should be on the key here, not the type string.
While in practice it doesn't matter since both are unknown values, having it be wrong is needlessly confusing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
expect(await testData.getAll(), isEmpty); | ||
expect(log.single.method, 'clear'); | ||
}); | ||
test('setValueMap', () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This naming doesn't make it clear why it expects to throw. How about 'setValue with unsupported type'
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
* google/master: [webview_flutter_wkwebview] Add support for `WKUIDelegate` (flutter#4809) [camera] Restore compatibility with older Flutter (flutter#4885) [ci.yaml] Migrate to Cocoon scheduler (flutter#4884) Roll Flutter from adafd66 to 93c0c04 (6 revisions) (flutter#4880) [shared_preferences] upgraded ios to using pigeon (flutter#4732)
Pre-launch Checklist
dart format
.)[shared_preferences]
pubspec.yaml
with an appropriate new version according to the pub versioning philosophy, or this PR is exempt from version changes.CHANGELOG.md
to add a description of the change, following repository CHANGELOG style.///
).If you need help, consider asking for advice on the #hackers-new channel on Discord.