SDWebImage 不携带 Cookie 的问题


  OC 中常用的轮播图控件是 SDCycleScrollView,基本上满足轮播的需求,当然,如果需要花里胡哨的自定义样式,还是要自己改的…

  今天想记录的一个小问题是:轮播图要加载的图片需要通过认证才可以访问,而认证信息是由服务器颁发、通过 Cookie 下发到客户端。问题是轮播图没有自动携带 Cookie,导致认证失败、图片加载失败。

SDK 的版本:

SDCycleScrollView v1.82
SDWebImage v5.12.6

分析过程:

不想看的话,请直接跳到【结果】

  1. SDCycleScrollView 给每一个 cell 设置图片的位置为 SDCycleScrollView.m:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
SDCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:ID forIndexPath:indexPath];
...

if (!self.onlyDisplayText && [imagePath isKindOfClass:[NSString class]]) {
if ([imagePath hasPrefix:@"http"]) {
[cell.imageView sd_setImageWithURL:[NSURL URLWithString:imagePath] placeholderImage:self.placeholderImage];
} else {
...
}
}
...

return cell;
}

  可见,如果是网络图片,使用的 API 是 -[SDAnimatedImageView sd_setImageWithURL:placeholderImage:];,那如果处理 Cookie 的话,应该也是针对 SDWebImage

  1. SDWebImage -> SDAnimatedImageView.h 提供的几个 API 其实调用的都是同一个方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* Set the imageView `image` with an `url`, placeholder, custom options and context.
*
* The download is asynchronous and cached.
*
* @param url The url for the image.
* @param placeholder The image to be set initially, until the image request finishes.
* @param options The options to use when downloading the image. @see SDWebImageOptions for the possible values.
* @param context A context contains different options to perform specify changes or processes, see `SDWebImageContextOption`. This hold the extra objects which `options` enum can not hold.
* @param progressBlock A block called while image is downloading
* @note the progress block is executed on a background queue
* @param completedBlock A block called when operation has been completed. This block has no return value
* and takes the requested UIImage as first parameter. In case of error the image parameter
* is nil and the second parameter may contain an NSError. The third parameter is a Boolean
* indicating if the image was retrieved from the local cache or from the network.
* The fourth parameter is the original image url.
*/
- (void)sd_setImageWithURL:(nullable NSURL *)url
placeholderImage:(nullable UIImage *)placeholder
options:(SDWebImageOptions)options
context:(nullable SDWebImageContext *)context
progress:(nullable SDImageLoaderProgressBlock)progressBlock
completed:(nullable SDExternalCompletionBlock)completedBlock;

  需要注意一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
* @param options        The options to use when downloading the image. @see SDWebImageOptions for the possible values.

/// WebCache options
typedef NS_OPTIONS(NSUInteger, SDWebImageOptions) {
...
/**
* Handles cookies stored in NSHTTPCookieStore by setting
* NSMutableURLRequest.HTTPShouldHandleCookies = YES;
*/
SDWebImageHandleCookies = 1 << 5,
...
};

  我看网上大部分博客都在说,把这一个选项设置一下,在 SDCycleScrollView.m 中使用另一个 API 设置图片就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
SDCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:ID forIndexPath:indexPath];
...

if (!self.onlyDisplayText && [imagePath isKindOfClass:[NSString class]]) {
if ([imagePath hasPrefix:@"http"]) {
[cell.imageView sd_setImageWithURL:[NSURL URLWithString:imagePath] placeholderImage:self.placeholderImage options:SDWebImageHandleCookies];
} else {
...
}
}
...

return cell;
}

  首先,对于改三方 SDK 源码的行为,小咪是相当鄙视的😂,其次,改了以后并不管用呀😂,是我哪里理解错了吗…不知道那些博客是不是互相抄来抄去,标点符号都一模一样。
  但既然 SDWebImage 的注释都说了,设置 cookie 就是用这个 option,那我们点进去看看为什么没起作用,一层一层调用关系的查看,看到最后下载图片的操作都是在同一个地方:SDWebImageDownloader

  1. SDWebImageDownloader.m 中把 SDWebImageHandleCookies 转为了 SDWebImageDownloaderHandleCookies,两个 option 的含义其实是一样的。
1
2
3
4
5
6
7
8
9
10
- (id<SDWebImageOperation>)requestImageWithURL:(NSURL *)url options:(SDWebImageOptions)options context:(SDWebImageContext *)context progress:(SDImageLoaderProgressBlock)progressBlock completed:(SDImageLoaderCompletedBlock)completedBlock {
UIImage *cachedImage = context[SDWebImageContextLoaderCachedImage];

SDWebImageDownloaderOptions downloaderOptions = 0;
...
if (options & SDWebImageHandleCookies) downloaderOptions |= SDWebImageDownloaderHandleCookies;
...

return [self downloadImageWithURL:url options:downloaderOptions context:context progress:progressBlock completed:completedBlock];
}

  继续往 return 的下一个方法里看,就能找到关键的位置了:

  在下面这个方法中初始化了一个 SDWebImageDownloaderOperationSDWebImageDownloadToken 用来发请求下载图片,但是在这里拦截到的 request header 还是没有 cookie 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (nullable SDWebImageDownloadToken *)downloadImageWithURL:(nullable NSURL *)url
options:(SDWebImageDownloaderOptions)options
context:(nullable SDWebImageContext *)context
progress:(nullable SDWebImageDownloaderProgressBlock)progressBlock
completed:(nullable SDWebImageDownloaderCompletedBlock)completedBlock {
...

SD_LOCK(_operationsLock);
id downloadOperationCancelToken;
NSOperation<SDWebImageDownloaderOperation> *operation = [self.URLOperations objectForKey:url];
// There is a case that the operation may be marked as finished or cancelled, but not been removed from `self.URLOperations`.
if (!operation || operation.isFinished || operation.isCancelled) {
operation = [self createDownloaderOperationWithUrl:url options:options context:context];
...
} else {
...
}
SD_UNLOCK(_operationsLock);

SDWebImageDownloadToken *token = [[SDWebImageDownloadToken alloc] initWithDownloadOperation:operation];
token.url = url;
token.request = operation.request;
token.downloadOperationCancelToken = downloadOperationCancelToken;

return token;
}

继续往下看 ↓

operation = [self createDownloaderOperationWithUrl:url options:options context:context];

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (nullable NSOperation<SDWebImageDownloaderOperation> *)createDownloaderOperationWithUrl:(nonnull NSURL *)url
options:(SDWebImageDownloaderOptions)options
context:(nullable SDWebImageContext *)context {
NSTimeInterval timeoutInterval = self.config.downloadTimeout;
if (timeoutInterval == 0.0) {
timeoutInterval = 15.0;
}

// In order to prevent from potential duplicate caching (NSURLCache + SDImageCache) we disable the cache for image requests if told otherwise
NSURLRequestCachePolicy cachePolicy = options & SDWebImageDownloaderUseNSURLCache ? NSURLRequestUseProtocolCachePolicy : NSURLRequestReloadIgnoringLocalCacheData;
NSMutableURLRequest *mutableRequest = [[NSMutableURLRequest alloc] initWithURL:url cachePolicy:cachePolicy timeoutInterval:timeoutInterval];
mutableRequest.HTTPShouldHandleCookies = SD_OPTIONS_CONTAINS(options, SDWebImageDownloaderHandleCookies);
mutableRequest.HTTPShouldUsePipelining = YES;
SD_LOCK(_HTTPHeadersLock);
mutableRequest.allHTTPHeaderFields = self.HTTPHeaders;
SD_UNLOCK(_HTTPHeadersLock);

...

return operation;
}

1
2
// 这句 SDWebImageDownloaderHandleCookies 还确实是加了。
mutableRequest.HTTPShouldHandleCookies = SD_OPTIONS_CONTAINS(options, SDWebImageDownloaderHandleCookies);

  但是…注意被小🔐包围的这句代码,我们知道 cookie 是 HTTP 协议头部信息中的一个字段,在这里使用 self.HTTPHeaders 覆盖了 allHTTPHeaderFields,会导致所有的头部信息都是空的,因为我们没有单独给 self.HTTPHeaders 添加内容。

1
2
3
SD_LOCK(_HTTPHeadersLock);
mutableRequest.allHTTPHeaderFields = self.HTTPHeaders;
SD_UNLOCK(_HTTPHeadersLock);

分析过程 End…

结果:

找一个合适的位置给 SDWebImageDownloader 添加必要的头部信息就可以了。

1
2
3
4
5
6
7
8
9
10
//给SD设置一个新的cookie
NSString *sdCookie = @"";
for (NSHTTPCookie *cookie in [NSHTTPCookieStorage sharedHTTPCookieStorage].cookies) {
sdCookie = [sdCookie stringByAppendingFormat:@"%@=%@;", cookie.name, cookie.value];
}
if ([sdCookie hasSuffix:@";"]) {
sdCookie = [sdCookie substringToIndex:sdCookie.length - 1];
}
SDWebImageDownloader *downloader = [SDWebImageManager sharedManager].imageLoader;
[downloader setValue:sdCookie forHTTPHeaderField:@"Cookie"];

[HTTP Cookie 文档参考]可以随便捡一个网络请求的 header 看看内容…

  1. 服务端下发 cookie 是通过响应标头中 Set-Cookie 字段,多个 cookie 就加多个 Set-Cookie,模拟一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
🤓  ~  curl 'https://tommygirl.cn/httpbin/cookies/set?yummy_cookie=choco&tasty_cookie=strawberry' -i
HTTP/1.1 302 FOUND
Server: nginx
Date: Thu, 08 Dec 2022 08:52:34 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 223
Connection: keep-alive
Location: /cookies
Set-Cookie: yummy_cookie=choco; Path=/
Set-Cookie: tasty_cookie=strawberry; Path=/
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to target URL: <a href="/cookies">/cookies</a>. If not click the link.%
  1. 客户端访问服务器时,会根据 cookie 的路径和有效期等属性,选择携带哪些 cookie,对应请求标头中 Cookie 字段,多个 cookie 使用 ; 分隔,模拟一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
🤓  ~  curl 'https://tommygirl.cn/httpbin/anything' -i
HTTP/1.1 200 OK
Server: nginx/1.20.1
Date: Thu, 08 Dec 2022 09:01:06 GMT
Content-Type: application/json
Content-Length: 390
Connection: keep-alive
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *

{
"args": {},
"data": "",
"form": {},
"headers": {
"Accept": "*/*",
"Connection": "close",
"Host": "localhost:8000",
"User-Agent": "curl/7.79.1",
"Cookie": "yummy_cookie=choco; tasty_cookie=strawberry",
"X-Forwarded-For": "*.*.*.*",
"X-Real-Ip": "*.*.*.*"
},
"json": {},
"method": "GET",
"url": "/anything",
"server_endpoints": [
"172.21.0.13:8000"
],
"client": "127.0.0.1:34076"
}%

  至于 Cookie 末尾的 ; 可删可不删,功能上暂时没发现有什么影响,但既然格式都是 HTTP 标准规定的,那还是删一下吧。