iOS suggestedFilename 中文文件名乱码问题

日常吐槽:

App 中有个下载附件的功能,预览附件的时候会显示文件名字。但有个人艾特我说,安卓的没问题,IOS 的不行,文件名显示乱码,就问你尴不尴尬?……

通常有人提 Bug 咱都虚心接受,但只要说了 安卓没问题 ,血压立马上升一格。有事儿说事儿,别提安卓……

看到写 IOS 而不是 iOS 的人,血压又上升一格……

想起来苹果开发最火的那几年,我接到过一个 HR 的电话,”请问您是做 ISO 开发的吗?” ,我当时心想这个公司不能去呀,HR 都这么不严谨,给我干 HR 好不好,拿来吧你……

suggestedFilename

1
NSString *filename = task.response.suggestedFilename;

说回正题,下载附件的代码中我们是通过 NSURLResponsesuggestedFilename 获取服务端建议的一个文件名,但是得到的却是类似这样的乱码 é��åº�å¸�工伤æ�¥é��ä½�é�¢ä¼�é£�è¡¥å�©è´¹ã��交é��é£�å®¿è´¹å®¡æ ¸ç»�ç®�表.xls ,满脸问号是不是??????

我们知道 suggestedFilename 来自 HTTP 响应的头部信息,只是苹果默认帮我们解析成了一个 NSURLResponse 对象, 乱码问题往往是字符的编码格式没有对应上,或者苹果并不知道应该按照哪个编码规则解析,把问题抛给了开发者自己处理。

那奇怪的是,有的附件正常,有的附件就凌乱了。自然我们就想到比对一下两者 Header 的区别,是骡子是马拉出来溜溜~~

下面是截取的几个与 Content 有关的信息:

  • 附件示例:测试素材 - 副本.docx 。文件名显示乱码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<NSHTTPURLResponse: 0x600003a44fa0> { URL: https://wwww.www.www } { Status Code: 201, Headers {
"Access-Control-Allow-Methods" = (
"POST,GET,PUT"
);
"Content-Disposition" = (
"form-data; name=\"attachment\"; filename=\"\U00e6\U00b5\U008b\U00e8\U00af\U0095\U00e7\U00b4\U00a0\U00e6\U009d\U0090 - \U00e5\U0089\U00af\U00e6\U009c\U00ac.docx\""
);
"Content-Length" = (
19498
);
"Content-Type" = (
"application/octet-stream;charset=UTF-8"
);
} }
  • 附件示例:上海隐私条款.docx。文件名显示正常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<NSHTTPURLResponse: 0x600003d919a0> { URL: https://wwww.www.www } { Status Code: 200, Headers {
"Access-Control-Allow-Methods" = (
"GET, POST, OPTIONS, PUT, DELETE, HEAD, TRACE"
);
"Content-Disposition" = (
"inline; filename*=UTF-8''%E4%B8%8A%E7%A0%94%E7%A7%BB%E5%8A%A8%E9%9A%90%E7%A7%81%E6%9D%A1%E6%AC%BE.docx"
);
"Content-Encoding" = (
gzip
);
"Content-Type" = (
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
} }

Content-DispositionContent-Type 有点区别。之前我总是误以为 Content-Type 中的 charset=UTF-8 除了指定文件的编码格式,文件名也是由这个指定的。大错特错了~~

第一次关注 Content-Disposition 。所以它到底是何方神圣呢?

Content-Disposition

在常规的 HTTP 应答中,Content-Disposition 属于响应头的一个属性,用于指示服务器响应给我们的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地。

语法

作为主体的响应标题

HTTP 上下文中的第一个参数是 inline(默认值,表示它可以显示在网页内,或作为网页)或 attachment(表示它应该下载;大多数浏览器呈现“另存为”对话框,预先填入filename,如果参数的值存在的话。

1
2
3
Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="filename.jpg"

作为多部分主体的 header

HTTP 上下文中的第一个参数总是form-data; 其他参数不区分大小写,并且有参数,在'='符号后面使用带引号的字符串语法。多个参数用分号(';')分隔。

1
2
3
Content-Disposition: form-data
Content-Disposition: form-data; name="fieldName"
Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"

指令

  • name

    后面是一个表单字段名的字符串,每一个字段名会对应一个子部分。在同一个字段名对应多个文件的情况下(例如,带有 multiple 属性的 <input type=file> 元素),则多个子部分共用同一个字段名。如果 name 参数的值为 '_charset_' ,意味着这个子部分表示的不是一个 HTML 字段,而是在未明确指定字符集信息的情况下各部分使用的默认字符集。

  • filename

    后面是要传送的文件的初始名称的字符串。这个参数总是可选的,而且不能盲目使用:路径信息必须舍掉,同时要进行一定的转换以符合服务器文件系统规则。这个参数主要用来提供展示性信息。当与 Content-Disposition: attachment 一同使用的时候,它被用作”保存为”对话框中呈现给用户的默认文件名。

  • filename *

    filenamefilename * 两个参数的唯一区别在于,filename * 采用了RFC 5987 中规定的编码方式。filenamefilename * 同时出现的时候,应该优先采用 filename * ,假如二者都支持的话。

    1
    filename*=UTF-8''testfile.docx

PS: 参考资料

回归到问题

回归到我们前面的问题,一个指定的 filename ,一个指定的 filename * ,有问题吗?没有问题。前面说了,都是标准的。只不过后者告诉了我们文件名使用的编码,而且还是普遍的系统和浏览器都支持的 UTF-8。前者既没有指定编码,也不是 UTF-8。我们能骂后台吗?不能,但我还是在心里骂了一句。

1
2
3
4
5
6
7
8
//乱码
"Content-Disposition" = (
"form-data; name=\"attachment\"; filename=\"\U00e6\U00b5\U008b\U00e8\U00af\U0095\U00e7\U00b4\U00a0\U00e6\U009d\U0090 - \U00e5\U0089\U00af\U00e6\U009c\U00ac.docx\""
);
//正常
"Content-Disposition" = (
"inline; filename*=UTF-8''%E4%B8%8A%E7%A0%94%E7%A7%BB%E5%8A%A8%E9%9A%90%E7%A7%81%E6%9D%A1%E6%AC%BE.docx"
);

与上面两种形式对应,解决办法也有两种:

  1. 服务端把 Content-Disposition 中的文件名编码加上。
  2. iOS 自己单独解析一下 suggestedFilename

我运气好,对接的业务方在服务端改了,态度还不错。那如果对方因客观原因改不了怎么办呢?采用方法2,但这个时候问题又来了,我们怎么知道用哪种编码来解析呢?

说到编码,日常我也只是用 NSUTF8StringEncoding 比较多。UTF-8 有点类似于 Haffman 编码,它将 Unicode 编码为 00000000-0000007F 的字符,用单个字节来表示;00000080-000007FF 的字符用两个字节表示;00000800-0000FFFF 的字符用3字节表示。因为目前为止 Unicode-16 规范没有指定 FFFF 以上的字符,所以 UTF-8 最多是使用3个字节来表示一个字符。但理论上来说,UTF-8 最多需要用6字节表示一个字符? 🤓

NSStringEncoding

一组常量,NSString 可能用到的字符串编码。

Constants

NSStringEncoding Notes
NSASCIIStringEncoding - 8 位字符内的严格 7 位 ASCII 编码;仅 ASCII 值 0…127。
NSNEXTSTEPStringEncoding - 带有 NEXTSTEP 扩展的 8 位 ASCII 编码。
NSJapaneseEUCStringEncoding - 日语文本的 8 位 EUC 编码。
NSUTF8StringEncoding - Unicode 字符的 8 位表示,适合由基于 ASCII 的系统传输或存储。
NSISOLatin1StringEncoding - 8 位 ISO Latin 1 编码。又叫 ISO-8859-1 编码。
NSSymbolStringEncoding - 8 位 Adobe 符号编码向量。
NSNonLossyASCIIStringEncoding - 7 位详细 ASCII 表示所有 Unicode 字符。??
NSShiftJISStringEncoding - 日语文本的 8 位 Shift-JIS 编码。
NSISOLatin2StringEncoding - 8 位 ISO Latin 2 编码。又叫 ISO-8859-2 编码。
NSUnicodeStringEncoding - 字符串对象的规范 Unicode 编码。是一种2字节编码,能够提供65536个字符,如”A”的Unicode编码为6500,而BigEndianUnicode编码为0065
NSWindowsCP1251StringEncoding - Microsoft Windows 代码页 1251,编码 Cyrillic 字符;相当于 AdobeStandardCyrillic 字体编码。
NSWindowsCP1252StringEncoding - Microsoft Windows 代码页 1252;相当于 WinLatin1。
NSWindowsCP1253StringEncoding - Microsoft Windows 代码页 1253,编码希腊字符。
NSWindowsCP1254StringEncoding - Microsoft Windows 代码页 1254,编码土耳其语字符。
NSWindowsCP1250StringEncoding - Microsoft Windows 代码页 1250;相当于 WinLatin2。
NSISO2022JPStringEncoding - 电子邮件的 ISO 2022 日语编码。GB 2312 遵从于 ISO 2022。
NSMacOSRomanStringEncoding - 经典的 Macintosh 罗马编码。主要用于编码 Classic Mac OS 上的文字。
NSUTF16StringEncoding - 16 位 UTF 编码。同 NSUnicodeStringEncoding。
NSUTF16BigEndianStringEncoding - 指定字节序的 UTF-16 编码。字节序为大尾,也叫大端。
NSUTF16LittleEndianStringEncoding - 指定字节序的 UTF-16 编码。字节序为小尾,也叫小端。
NSUTF32StringEncoding - 32 位 UTF 编码。
NSUTF32BigEndianStringEncoding - 指定字节序的 UTF-32 编码。字节序为大尾,也叫大端。
NSUTF32LittleEndianStringEncoding - 指定字节序的 UTF-32 编码。字节序为小尾,也叫小端。

字节序

对于整型、长整型等数据类型,Big-endian 认为第一个字节是最高位字节,即按照从低地址到高地址的顺序存放数据的高位字节到低位字节,称为大端、大尾;而 Little-endian 则相反,它认为第一个字节是最低位字节,按照从低地址到高地址的顺序存放据的低位字节到高位字节,称为小端、小尾。

例如,假设从内存地址 0x0000 开始有以下数据:

0x0000 0x0001 0x0002 0x0003
0x12 0x34 0xab 0xcd

如果我们去读取一个地址为 0x0000 的四个字节变量,若字节序为 Big-endian,则读出结果为 0x1234abcd;若字节序为 Little-endian,则读出结果为 0xcdab3412。

如果我们将 0x1234abcd 写入到以 0x0000 开始的内存中,则 Little-endian 和 Big-endian 模式的存放结果如下:

地址 0x0000 0x0001 0x0002 0x0003
Big-endian 0x12 0x34 0xab 0xcd
Little-endian 0xcd 0xab 0x34 0x12

端 (endian) 的起源

endian”一词来源于十八世纪爱尔兰作家乔纳森·斯威夫特的小说《格列佛游记》。小说中,小人国为水煮蛋该从大的一端(Big-End)剥开还是小的一端(Little-End)剥开而争论,争论的双方分别被称为“大端派”和“小端派”。以下是1726年关于大小端之争历史的描述:

我下面要告诉你的是,Lilliput和Blefuscu这两大强国在过去36个月里一直在苦战。战争开始是由于以下的原因:我们大家都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端,可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了。因此他的父亲,当时的皇帝,就下了一道敕令,命令全体臣民吃鸡蛋时打破鸡蛋较小的一端,违令者重罚。老百姓们对这项命令极其反感。历史告诉我们,由此曾经发生过6次叛乱,其中一个皇帝送了命,另一个丢了王位。这些叛乱大多都是由Blefuscu的国王大臣们煽动起来的。叛乱平息后,流亡的人总是逃到那个帝国去寻求避难。据估计,先后几次有11000人情愿受死也不肯去打破鸡蛋较小的一端。关于这一争端,曾出版过几百本大部著作,不过大端派的书一直是受禁的,法律也规定该派任何人不得做官。”

觉得这个鸡蛋的故事很有意思,所以粘在这里了 😂。关于字节序的更多内容见 维基百科-字节序 ,很详细了。

字符集

关于字符集有些我也看不懂,百科上倒是解释的比较清楚。还有很多字符编码集:

撞大运

这么多种编码,iOS 解析的话,我们用哪一种呢?如果对接的业务少,可以和服务端约定好。如果对接的业务多,想兼容所有的”乱码”,我觉得只能撞大运了😂

示例:

1
2
3
4
5
NSString *fileName, *str;
const char *byte = NULL;
fileName = [task.response suggestedFilename];
byte = [fileName cStringUsingEncoding:NSISOLatin1StringEncoding];
str = [[NSString alloc] initWithCString:byte encoding:NSUTF8StringEncoding];

在上面的示例中,如果指定的 NSStringEncoding 不匹配,str 会是 nil,所以我们可以利用 nil 来撞大运了,有毛病吗?可能有,哈哈 😂