New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Don't force completion blocks to be scheduled on the main queue #68
Conversation
Yesssss yes yes, excellent find |
The original reasoning for doing this is to mimic the existing behavior of popular libraries like AlamoFire. As a user, it drives me crazy to have to do this at the app level, especially since these kinds of requests tend to require UI updates 80% of the time. I think completely removing the behavior might not be the best idea, but I think injecting a specific queue to perform the completion block on might be interesting. For example, something like: diff --git a/Source/Models/APIClient.swift b/Source/Models/APIClient.swift
index 83bba08..52c75c4 100644
--- a/Source/Models/APIClient.swift
+++ b/Source/Models/APIClient.swift
@@ -11,10 +11,10 @@ public struct APIClient {
}
extension APIClient: Client {
- public func performRequest<T: Request>(request: T, completionHandler: Result<T.ResponseObject, T.ResponseError> -> Void) -> NSURLSessionDataTask {
+ public func performRequest<T: Request>(request: T, completionQueue: dispatch_queue_t = dispatch_get_main_queue(), completionHandler: Result<T.ResponseObject, T.ResponseError> -> Void) -> NSURLSessionDataTask {
return requestPerformer.performRequest(request.build()) { result in
let object = (result >>- deserialize >>- request.parse).mapError(request.transformError)
- onMain { completionHandler(object) }
+ dispatch_async(completionQueue) { completionHandler(object) }
}
}
}
diff --git a/Source/Protocols/Client.swift b/Source/Protocols/Client.swift
index c5df687..0935756 100644
--- a/Source/Protocols/Client.swift
+++ b/Source/Protocols/Client.swift
@@ -3,5 +3,5 @@ import Argo
import Result
public protocol Client {
- func performRequest<T: Request>(request: T, completionHandler: Result<T.ResponseObject, T.ResponseError> -> ()) -> NSURLSessionDataTask
+ func performRequest<T: Request>(request: T, completionQueue: dispatch_queue_t, completionHandler: Result<T.ResponseObject, T.ResponseError> -> ()) -> NSURLSessionDataTask
} This still performs a call to I don't know that this necessarily fixes the issue, though. I'd be interested to know if this change (in the PR, not my diff) alone is enough to solve the performance issues @sidraval has seen in his client project. |
Another thought: what's the actual issue here? Is the basic problem that chaining multiple requests results in introduced latency? If so, maybe there's a better way we can specify request chains or request dependencies? |
I think this summarises the issues for me:
I like the idea of being able to inject a queue. What do you think about making the queue optional, so if one is not specified the completion block will be executed synchronously? The main queue could potentially also be the default. |
So you're suggesting something like: diff --git a/Source/Models/APIClient.swift b/Source/Models/APIClient.swift
index 930a54c..093e2d4 100644
--- a/Source/Models/APIClient.swift
+++ b/Source/Models/APIClient.swift
@@ -11,10 +11,14 @@ public struct APIClient {
}
extension APIClient: Client {
- public func performRequest<T: Request>(request: T, completionHandler: Result<T.ResponseObject, SwishError> -> Void) -> NSURLSessionDataTask {
+ public func performRequest<T: Request>(request: T, queue: dispatch_queue_t? = dispatch_get_main_queue(), completionHandler: Result<T.ResponseObject, SwishError> -> Void) -> NSURLSessionDataTask {
return requestPerformer.performRequest(request.build()) { result in
let object = result >>- deserialize >>- request.parse
- onMain { completionHandler(object) }
+ if let queue = queue {
+ dispatch_async(queue) { completionHandler(object) }
+ } else {
+ completionHandler(object)
+ }
}
}
}
diff --git a/Source/Protocols/Client.swift b/Source/Protocols/Client.swift
index 9104b9c..6af3cf2 100644
--- a/Source/Protocols/Client.swift
+++ b/Source/Protocols/Client.swift
@@ -3,5 +3,5 @@ import Argo
import Result
public protocol Client {
- func performRequest<T: Request>(request: T, completionHandler: Result<T.ResponseObject, SwishError> -> ()) -> NSURLSessionDataTask
+ func performRequest<T: Request>(request: T, queue: dispatch_queue_t?, completionHandler: Result<T.ResponseObject, SwishError> -> ()) -> NSURLSessionDataTask
} |
Yes, something like that. What do you think about a property on |
I guess the two arguments would be:
So that means that if we assign the queue at the instance level, we'd have to create two clients to be able to satisfy the second argument. Conversely if we pass the queue in, there's a little bit of duplication at the call site when performing the request. I'm honestly not sure which makes more sense, but I feel like I'm still leaning towards injecting the queue at the point where we perform the request. |
The only other issue I can think of with passing the queue in as a parameter to For that reason I think I'd prefer a parameter on FWIW, I tried to see if we could allow both. Theoretically, that would look something like this: public struct APIClient {
var queue: dispatch_queue_t? = dispatch_get_main_queue()
}
extension APIClient: Client {
public func performRequest<T: Request>(request: T, queue: dispatch_queue_t? = self.queue, completionHandler: Result<T.ResponseObject, SwishError> -> Void) -> NSURLSessionDataTask {
// ...
}
} But alas, you can't access |
Yeah, I think it's just a difference of opinion. Maybe the rest of @thoughtbot/ios can break the tie? |
This is a tough call for me ... I can see what @sharplet wants and it makes sense; however, my 90% use case is that I want the call the main thread anyways so I like that swish does it for me. Would it be too much to have a second What about a second completion block? |
@tonyd256 I think I'd prefer that to a single method call with a default parameter, because then they'll show up as two different autocomplete options in Xcode. Although having said that, I'm now thinking that Xcode probably does the same thing for a single method with a default parameter! An issue with having two versions that only differ by the label for the completion block is that when using trailing closure syntax it's now ambiguous which will be used (or maybe it just won't compile?). |
Regarding our difference of opinion: I've pushed 321aa79 which implements the |
Seriously good point about trailing closure syntax. I am perfectly fine with the client based queue config since it doesn't require any config if you just want to default to the main queue. So, my vote's for that. |
Though, it would be nice to also have a method that takes a queue that takes first priority over the default queue. Because it would be a pain to have to set the default queue property often. So I want both, yes. 😁 |
Also just pushed some tests for the queue handling stuff! |
In that case I'm thinking I'd probably instantiate a single |
isOnMain = NSThread.isMainThread() | ||
} | ||
|
||
expect(isOnMain).toEventually(beFalse()) |
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 this actually tests anything. isOnMain
is false
by default, so this can't fail. Would probably be better to default it to true
and remove the optionality.
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.
No, it's nil
by default. The matcher will take care of the nil
case, failing the test with a "use beNil() to match nils" message.
I don't think we should add multiple I'm still not convinced that a property is the way to go here. I'm also a little worried that this is introducing complexity to the API based on speculation. Do we have any actual proof that this is an issue and that this is an actual fix? I thought I remembered that @sidraval said removing the queue stuff in the client app he was having performance issues in didn't result in any measurable performance improvement, but maybe I'm misremembering? I'm very torn on this. |
FWIW, if we close this PR I'll still consider it a successful discussion. As far as API design goes, that's exactly what this PR is: speculation! I think it's valuable to have something concrete to discuss. So you raise a good point that so far my advocating for this hasn't been particularly concrete, and really my argument is more an appeal to principal. I personally believe that it's up to the app to decide whether dispatching everything onto the main queue is the correct default. I'm not convinced that it always is, and so I'm not convinced that Swish should force that policy on every app. In practice, perhaps it's super useful and the potential performance problems I'm citing are a non-issue. I'm totally happy if that's the case! I'd just feel more comfortable if this wasn't hard-coded. |
I'm in agreement with @sharplet here. @gfontenot I did say that, though to be clear it was a 30m punt where I just quickly hacked it together to see if was the only problem. However, reading the code made way more sense, because previously my RAC requests would wrapped in an (__request code__).observeOn(UIScheduler()).start { which is more explicit, and clearer IMO. |
Pipe Dream: What if there was a Background Monad and we had a function Probably not easy but maybe gets the brain juices flowing for something. |
Here's another option brought up by @jnutting that might actually be better than anything we've suggested so far: diff --git a/Source/Models/APIClient.swift b/Source/Models/APIClient.swift
index 930a54c..f924e1d 100644
--- a/Source/Models/APIClient.swift
+++ b/Source/Models/APIClient.swift
@@ -11,10 +11,11 @@ public struct APIClient {
}
extension APIClient: Client {
- public func performRequest<T: Request>(request: T, completionHandler: Result<T.ResponseObject, SwishError> -> Void) -> NSURLSessionDataTask {
+ public func performRequest<T: Request>(request: T, scheduler: (() -> Void) -> Void = onMain, completionHandler: Result<T.ResponseObject, SwishError> -> Void) -> NSURLSessionDataTask {
return requestPerformer.performRequest(request.build()) { result in
let object = result >>- deserialize >>- request.parse
- onMain { completionHandler(object) }
+
+ scheduler { completionHandler(object) }
}
}
}
diff --git a/Source/Protocols/Client.swift b/Source/Protocols/Client.swift
index 9104b9c..f35d966 100644
--- a/Source/Protocols/Client.swift
+++ b/Source/Protocols/Client.swift
@@ -3,5 +3,5 @@ import Argo
import Result
public protocol Client {
- func performRequest<T: Request>(request: T, completionHandler: Result<T.ResponseObject, SwishError> -> ()) -> NSURLSessionDataTask
+ func performRequest<T: Request>(request: T, scheduler: (() -> Void) -> Void, completionHandler: Result<T.ResponseObject, SwishError> -> ()) -> NSURLSessionDataTask
} So instead of passing in a queue, we just pass in a closure. And that closure can do whatever the hell it wants to do. It could just be This seems way more flexible than anything else we've talked about. There's still the question about where to pass it, but I'll concede to the group since it seems like people prefer passing it at the time of initialization. That would also make this a non-breaking change as well. |
I agree that the |
FWIW, I've pushed a version that uses the |
@@ -13,15 +17,19 @@ public struct APIClient { | |||
} | |||
|
|||
extension APIClient: Client { | |||
public func performRequest<T: Request>(request: T, completionHandler: Result<T.ResponseObject, SwishError> -> Void) -> NSURLSessionDataTask { | |||
public func performRequest<T: Request>(request: T, scheduler schedule: ((() -> Void) -> Void)? = mainQueueScheduler, completionHandler: Result<T.ResponseObject, SwishError> -> Void) -> NSURLSessionDataTask { |
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 the optional is worth it here? It seems like a bit of magic, where consumers could very easily do
let directScheduler: (() -> Void) -> Void = { $0 }
and get the same behavior without adding complexity here or adding semantics to passing .None
.
Only remaining question is about the use of the optional for the scheduler. I'm also curious about if you wanted to move the scheduler to the |
Different request performers are allowed to have different semantics when it comes to the queue that results are delivered on. If a performer already happens to deliver results on the main queue, `APIClient` is injecting latency by scheduling an unnecessary async call. If a performer delivers results on a background queue (i.e., `NetworkRequestPerformer`), and there's further processing that would benefit from occurring in the background (e.g., image processing or decoding a large object), callers must dispatch back onto a background queue to avoid blocking the UI. It also hampers composability of requests, because multiple chained requests will incur a penalty from jumping back and forth from the main queue to a background queue. This breaking change removes the `dispatc_async`, replacing it with the more abstract idea of a `Scheduler`, which is a block that schedules the completion handler to be performed, either synchronously or asynchronously. The `mainQueueScheduler` is the default, and an `immediateScheduler` is provided to synchronously invoke the completion block instead.
Ended up switching to the non-optional version of the scheduler, and introduced |
Also I just pushed a second commit that shares the |
@@ -0,0 +1,9 @@ | |||
public typealias Scheduler = ((() -> Void) -> 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.
nice
(This PR depends on #67.)
Different request performers are allowed to have different semantics when it comes to the queue that results are delivered on. If a performer already happens to deliver results on the main queue,
APIClient
is injecting latency by scheduling an unnecessary async call. If a performer delivers results on a background queue (i.e.,NetworkRequestPerformer
), and there's further processing that would benefit from occurring in the background (e.g., image processing or decoding a large object), callers must dispatch back onto a background queue to avoid blocking the UI. It also hampers composability of requests, because multiple chained requests will incur a penalty from jumping back and forth from the main queue to a background queue.This breaking change removes the
dispatc_async
and leaves that decision to callers, as it's not possible to anticipate the correct decision to use in every situation.