I need to implement like/unlike functionality in app. All API calls are made with AFNetworking
and success/errors handlers (ios blocks).
Problem is that when user making many clicks on a button in a short period of time, some of the request are received by server in a wrong sequence and then everything becomes wrong. For example double like or double unlike happens.
Is there a way to send all request via AFNetworking
synchronously?
If no what is the best practice to design this kind of API requests?
Disabling the button (like the comments suggest) is not a bad idea, especially if you throw a spinner or some UI change to let the user know you are processing the change.
Otherwise, you could just limit the API calls to only allow a single call to be out. If the user presses the button, fire the call and change some boolean or tracking value. If they press the button again hold unto the change state locally but wait for the first callback to come in. If they keep pressing the button just keep track of their change but never fire the response until you receive your notification that the API call has completed (probably with a 10-30 second timeout in case it fails).
Once the call is completed, see if the new value the user wants is different. If it is, send that and prevent future changes from going out (but track them locally), if it is the same (the user pressed the button an even amount of times while your first call was out) then don't send it.
I would even delay the first call by 3 or so seconds and every time they press the button within that time period reset the timer. That way you will not be firing accidental calls unnecessarily (think of it as a coredata save, if you know there may be a few changes you make them all before saving).
The problem with a synchronized queue is if they press the button five times (or more) it will have a pretty long wait queue. Then what if they close the application and your calls are not sent? Then your database has (potentially) inaccurate information.
The easiest way to do this, IMHO, is to disable the button just before sending the request. Once you have the response in the success or failure callback, you can make UI changes to give feedback that the user has liked whatever he has liked and you can enable the button again.
It strikes me that you have two options:
The simple solution is to give the user positive UI feedback that the button was tapped, such as suggested by Moxy (i.e. a UX that prevents the "hey, I should tap on that again because it doesn't look like I got it last time"), but then disable further interaction with that button until the previous action is complete. Or,
The more complicated solution is to reflect the like/unlike change in the UI immediately and manage the network requests asynchronously (and not just in terms of the threads, but logically, too). If you do this, you will want to keep a weak
reference to the previous like/unlike operation (and operation queues are great for this sort of problem) for each like/unlike button, such that when you make new a like/unlike request, you can make its operation dependent upon the prior one (so they happen sequentially), and/or cancel the prior one.
AFNetworking operations will return before completion event if you put them in an operation queue. Check this blog post: http://www.dribin.org/dave/blog/archives/2009/05/05/concurrent_operations/
In your case, you need to create an NSOperation subclass similar to the following:
//Header file
@interface LikeOperation : NSOperation
@property (readonly, nonatomic) BOOL isExecuting;
@property (readonly, nonatomic) BOOL isFinished;
+ (instancetype)operationWithCompletionSuccessBlock:(void(^)())onSuccess failure:(void(^)(NSError *anError))onError;
@end
//Implementation file
#import "LikeOperation.h"
typedef void (^SuccessBlock)();
typedef void (^ErrorBlock)(NSError*);
@interface LikeOperation()
@property (readwrite, copy, nonatomic) SuccessBlock onSuccess;
@property (readwrite, copy, nonatomic) ErrorBlock onError;
@property (assign, nonatomic) BOOL isExecuting;
@property (assign, nonatomic) BOOL isFinished;
@property (readwrite, strong, nonatomic) AFHTTPClient *client;
@end
@implementation LikeOperation
static NSString *const kBaseURLString = @"www.example.org";
static NSString *const kURLString = @"www.example.org/like";
- (id)initWithCompletionSuccessBlock:(void (^)())onSuccess failure:(void (^)(NSError *))onError
{
self = [super init];
if (self)
{
self.onSuccess = onSuccess;
self.onError = onError;
}
return self;
}
+ (instancetype)operationWithCompletionSuccessBlock:(void (^)())onSuccess failure:(void (^)(NSError *))onError
{
return [[self alloc] initWithCompletionSuccessBlock:onSuccess
failure:onError];
}
- (void)start
{
if (![NSThread isMainThread])
{
[self performSelectorOnMainThread:@selector(start)
withObject:nil
waitUntilDone:NO];
return;
}
NSString *key = NSStringFromSelector(@selector(isExecuting));
[self willChangeValueForKey:key];
self.isExecuting = YES;
[self didChangeValueForKey:key];
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURL *baseURL = [NSURL URLWithString:kBaseURLString];
self.client = [AFHTTPClient clientWithBaseURL:baseURL];
});
NSURL *url = [NSURL URLWithString:kURLString];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
AFHTTPRequestOperation *operation = [self.client HTTPRequestOperationWithRequest:request success:^(AFHTTPRequestOperation *operation, id responseObject) {
self.onSuccess();
} failure:^(AFHTTPRequestOperation *operation, NSError *error) {
self.onError(error);
}];
[operation start];
}
- (void)finish
{
NSString *isExecutingKey = NSStringFromSelector(@selector(isExecuting));
NSString *isFinishedKey = NSStringFromSelector(@selector(isFinished));
[self willChangeValueForKey:isExecutingKey];
[self willChangeValueForKey:isFinishedKey];
self.isExecuting = NO;
self.isFinished = YES;
[self didChangeValueForKey:isExecutingKey];
[self didChangeValueForKey:isFinishedKey];
}
@end
After that, you can put the above operation safely in an NSOperationQueue and set the max concurrent maxConcurrentOperationCount to 1 so that the operations run one after the another. You might also want to explore nsoperation dependencies, as explained in http://developer.apple.com/library/mac/#documentation/Cocoa/Reference/NSOperation_class/Reference/Reference.html
//Code to initialize the operation queue
self.queue = [[NSOperationQueue alloc] init];
self.queue.name = @"Post data queue";
self.queue.maxConcurrentOperationCount = 1;
//perform like
- (void)like
{
NSOperation *likeOperation = [LikeOperation operationWithCompletionSuccessBlock:^{
} failure:^(NSError *anError) {
}];
[self.queue addOperation:likeOperation];
}
For Swift4 I have managed it with queue
import UIKit
import Alamofire
class LikeOperation: Operation {
private var _isExecuting = false
private var _finished = false
private var request:DataRequest? = nil
private var imageID:String
typealias completionBlock = ((GeneralResponse<User>?) -> Void)?
var finishedBlock : completionBlock
init(imageID:String, completionBlock:completionBlock) {
self.imageID = imageID
self.finishedBlock = completionBlock
super.init()
}
override var isExecuting: Bool {
get {
return _isExecuting
} set {
willChangeValue(forKey: "isExecuting")
_isExecuting = isExecuting
didChangeValue(forKey: "isExecuting")
}
}
override var isFinished: Bool {
get {
return _finished
} set {
willChangeValue(forKey: "isFinished")
_finished = newValue
didChangeValue(forKey: "isFinished")
}
}
override func start() {
if isCancelled {
isFinished = true
return
}
isExecuting = true
func completeOperation() {
isFinished = true
isExecuting = false
}
self.request = APIClient.insertImageLike(ImageID: self.imageID, completion: { (completion:GeneralResponse<User>?, error) in
self.finishedBlock?(completion!)
completeOperation()
})
}
override func cancel() {
super.cancel()
if isExecuting {
isFinished = true
isExecuting = false
}
request?.cancel()
}
}
func callAPIToLike (post:Post) {
guard let id = post.id else {
self.showAlert(withMessage: ErrorMessages.General.somethingWentWrong)
return
}
AppGlobalManager.sharedInstance.homeScreenLikeAPIQueue.cancelAllOperations()
let operation = LikeOperation.init(imageID: "\(id)") { (object) in
}
AppGlobalManager.sharedInstance.homeScreenLikeAPIQueue.addOperation(operation)
}