1. 程式人生 > >一個iOS專案總結(一):網路介面的封裝

一個iOS專案總結(一):網路介面的封裝

寫在前面

今年暑假,自己獨立完成了一個簡單的iOS的APP,是一個bbs的客戶端,叫做喻信星空。現在正在測試,準備將其上架app store。但是光做專案不做總結肯定不行,所以這裡寫篇部落格,把專案裡遇到的坑都記錄下來,不過這篇部落格裡肯定是有乾貨的,所以如果你看到了這裡,希望你能把它看完,並頂我一下(^-^)
這是一系列部落格,此係列共四篇文章,此篇部落格是該系列部落格的第一篇:網路介面的封裝。

正文

從抓包開始

這個bbs已經有了安卓版的客戶端,所以我就直接用安卓應用來抓包,不得不說用Charles真的是抓包的神器,抓包的具體細節這裡就不詳細說了。

概述

在動手寫UI層的東西之前,我首先想到的是將網路請求封裝起來,在這裡我很不要臉的用SDK進行命名,想想自己能寫SDK也是很不錯啊。
那麼現在的問題是這個SDK該如何組織呢?具體來說有哪些類,分別負責什麼呢。對於像我這樣接觸程式設計不久的人來說在架構上的體會是很少的,雖然自己的目標一直是成為一名架構師,但是這是需要一個很長時間的積累才能再回頭看書進行總結,才能有所小成。所以我這裡幾乎沒有什麼設計,就是從實用的角度弄出了一個可以正常使用的東西,大家如果有更好的設計,歡迎在下面留言討論。
我的SDK分為3個部分:YuXinSDK,YuXinModel,YuXinXmlParser,其中YuXinSDK作為一個主要的部分,網路請求都封裝在這個類中,使用這個SDK時也只要匯入這個類的標頭檔案就可以了。YuXinModel從名字就能看出來是做什麼的,是資料模型。最後YuXinXmlParser是一個Xml的解析器,對從網路返回的Xml資料進行解析生成YuXinModel,這個Model是一個基類,有很多繼承這個類不同的Model,當然這些Model不僅僅就是一個數據的承載器,在其內部也進行了資料的進一步加工(至於具體的加工什麼,後面會具體討論)。

字元編碼問題

由於這個bbs的伺服器是一個非常老的伺服器,它的編碼方式是gb2312,這裡也有一個小小的坑,在蘋果封裝的NSStringEncoding裡沒有gb2312的編碼方式

NSASCIIStringEncoding = 1,      
    NSNEXTSTEPStringEncoding = 2,
    NSJapaneseEUCStringEncoding = 3,
    NSUTF8StringEncoding = 4,
    NSISOLatin1StringEncoding = 5,
    NSSymbolStringEncoding = 6
, NSNonLossyASCIIStringEncoding = 7, NSShiftJISStringEncoding = 8, NSISOLatin2StringEncoding = 9, NSUnicodeStringEncoding = 10, NSWindowsCP1251StringEncoding = 11, NSWindowsCP1252StringEncoding = 12, NSWindowsCP1253StringEncoding = 13, /* Greek */ NSWindowsCP1254StringEncoding = 14
, /* Turkish */ NSWindowsCP1250StringEncoding = 15, /* WinLatin2 */ NSISO2022JPStringEncoding = 21, NSMacOSRomanStringEncoding = 30, NSUTF16StringEncoding = NSUnicodeStringEncoding, NSUTF16BigEndianStringEncoding = 0x90000100, NSUTF16LittleEndianStringEncoding = 0x94000100, NSUTF32StringEncoding = 0x8c000100, NSUTF32BigEndianStringEncoding = 0x98000100, NSUTF32LittleEndianStringEncoding = 0x9c000100

我試了上面大部分的編碼方式,很多都是英文沒有問題,但是中文在伺服器那邊都會變成亂碼
後來在網上看到有人說gb2312的編碼方式在更底層的CFStringEncodings中才有,使用時要將CFStringEncodings轉換為NSStringEncoding。然後在CFStringEnodings的列舉裡看到了kCFStringEncodingGB_2312_80 = 0x0630,當時欣喜若狂,覺得馬上就要成功了,但現實是殘酷的,中文字元還是會出問題,最後終於在網上發現了有人說實際上gb2312是kCFStringEncodingGB_18030_2000而不是kCFStringEncodingGB_2312_80,真的覺得蘋果這個命名太坑了,把我害苦了,寫著gb2312卻不是gb2312,真是服了。

xml解析(坑最多的地方)

下面說說解析從伺服器返回的xml資料,說真的現在伺服器返回的資料大多都是json了,返回xml也能看出來這個伺服器有多老了,正是因為老,所以有很多坑在裡面。
xml解析有兩種方式:SAX(Simple API for XML)和DOM (Document Object Model),SAX是從根元素開始,按順序一個元素一個元素往下解析,比較適合解析大檔案,而DOM一次性將整個XML文件載入進記憶體,比較適合解析小檔案,而我使用的Foundation中的NSXMLParser用的是SAX方式。

  • 控制字元

在使用NSXMLParser解析的過程中,會遇到某些返回的xml資料解析失敗的情況,錯誤程式碼也查不出有用的資訊,貌似網上沒有人遇到過這種錯誤,或許有隻是他沒有將自己的解決方法放到網上,或許網上有解決方式,只是我沒有能力找到,沒有辦法,只有自己解決了。
首先肯定是看看NSXMLParser在解析的時候發生了什麼,於是就在解析的回撥裡列印解析的資訊,然後我發現一個有趣的現象,對於那部分解析出問題的資料,每次都可以解析出部分資料,解析到中間的時候會出錯並停止解析。
根據這個現象我推測是因為xml中的部分資料導致解析出錯,那到底是什麼資料導致的呢,從伺服器返回的資料是NSData型別,而NSXMLParser需要傳入的資料也是NSData,但是NSData沒法檢視,我們將其轉換為NSString,我發現將其轉換為字串是沒有問題的,為了檢視這寫字串是否有問題,我將其複製到瀏覽器中,結果Chrome也無法解析,說是裡面有非法字元,並且告訴了我在哪一行哪一列,這下問題就定位出來了,我找到那一行那一列,有趣的是那裡有一個不可見的字元,這裡肯定有人覺得我在搞笑了,不可見字元我是怎麼看出來的呢?聽我慢慢道來。
當我們編輯文字時,肯定是有游標這個東西的,而我就是用游標發現了那裡的一個不可見字元的,我將游標定位到出問題的那一行,然後從左往右移動游標,我發現當游標移到瀏覽器報錯的那一列時,游標會有一個停頓,怎麼形容呢,就是那裡本來有一個字元,而你卻看不見,所以當你在那個字元前往後移動游標時,你會發現游標沒有動,而實際上游標已經跨過了那個不可見字元,當你再次移動游標時你看到的現象就完全恢復正常了。
那麼我這時候就很想知道那個作死的不可見的字元到底是什麼,我將那段文字複製到我的程式碼裡,然後將前後的字元刪去,將其按照%d輸出(其實這裡輸出的方式我進行了很多種嘗試,最後%d才真正有用),出人意料的我得到了27這個整型數字,通過查閱ASCII表

我發現27代表的是一個控制字元退出鍵,在維基百科裡我發現一個以前不知道的概念叫做脫出字元表示法,而退出鍵的脫出字元表示法是^[,而我從伺服器拿回的xml資料裡經常會出現這個^[字元,而且每個^[前都有一個不可見的ESC在裡面,這也印證了這個不可見字元就是一個退出建的控制符。

脫出字元表示法:通常用於終端機連線(例如Telnet通訊協議),以脫出字元^開頭,再接一個符號,用來讓這些控制字元得以在畫面上顯現。雖然看起來是兩個字元,但在終端機上實際只有一個字元。在絕大部分的終端機系統中,包括Windows的命令提示字元(cmd.exe)、Linux和FreeBSD,都可用Ctrl代表脫出字元,輸入想要的ASCII控制字元。例如想輸入空字元,就要輸入Ctrl+2,而非^@,後者會顯示成兩字元,前者只會顯示成一字元。

到了這裡,一切都沒有那麼難解釋了,這個bbs確實是在那種類似命令列的軟體裡使用的,而為了在裡面傳送彩色的文字,就需要在彩色文字前按下ESC鍵,再根據加入的引數,控制後面文字的顯示顏色。
問題發現了,那就很好解決了,將伺服器返回的資料中的ESC控制符去除掉即可,在我以為這樣就可以的時候,又出了一些其他問題,我發現這裡面的控制符不止ESC這一種,為了保證能得到完全有效的資料,我將可能出現的控制符全部檢查了一遍

NSStringEncoding gb2312 = CFStringConvertEncodingToNSStringEncoding(kCFStringEncodingGB_18030_2000);
    NSString *rawStr = [[NSString alloc] initWithData:data encoding:gb2312];
    NSString *refinedStr = rawStr;
    NSString *targetStr = @"\0\1\2\3\10\11\13\27\30\31\32\33\34\35\36\37";
    NSString *tmpStr;
    for (int i = 0; i < targetStr.length; i++) {
        tmpStr = [targetStr substringWithRange:NSMakeRange(i, 1)];
        refinedStr = [refinedStr stringByReplacingOccurrencesOfString:tmpStr withString:@""];
    }

這裡data就是從伺服器返回的資料

  • 非法字元

當我以為字元的處理可以告一段落進行後面的步驟的時候,又有問題出現了,NSXMLParser又有無法解析的資料了,這次要比上次更嚴重,因為將NSData轉換為NSString竟然失敗了,返回了null,於是我就從NSData轉NSString入手,我在Google裡輸入NSData to NSString null這幾個關鍵字,很快我便在StackOverflow裡找到了解決方案,這裡我將解決的程式碼貼出來:

- (NSData *)cleanGB2312:(NSData *)data {
    iconv_t cd = iconv_open("gb2312", "gb2312");
    int one = 1;
    iconvctl(cd, ICONV_SET_DISCARD_ILSEQ, &one);
    size_t inbytesleft, outbytesleft;
    inbytesleft = outbytesleft = data.length;
    char *inbuf  = (char *)data.bytes;
    char *outbuf = malloc(sizeof(char) * data.length);
    char *outptr = outbuf;
    if (iconv(cd, &inbuf, &inbytesleft, &outptr, &outbytesleft)
        == (size_t)-1) {
        NSLog(@"this should not happen, seriously");
        return nil;
    }
    NSData *result = [NSData dataWithBytes:outbuf length:data.length - outbytesleft];
    iconv_close(cd);
    free(outbuf);
    return result;
}

使用這段程式碼要匯入一個頭檔案#import "iconv.h",而這個iconv是GNU的一個庫libiconv。

/* Allocates descriptor for code conversion from encoding `fromcode' to
   encoding `tocode'. */
iconv_t iconv_open (const char* /*tocode*/, const char* /*fromcode*/);

使用的時候要注意,當tocode和fromcode不一樣時會出問題,所以最好不要用這個方法來轉換編碼,只是用它來去除非法字元。fromcode、tocode我只填過兩種:gb2312、utf-8,都沒有問題,其他的編碼方式應該都是類似的。

  • 編碼方式

當解決了前面兩個問題,我想這次應該不會再有問題了吧,然而卻事與願違,NSXMLParser又出現瞭解析失敗的情況,這次我真的搞不懂了,還能有什麼原因呢,難道是NSXMLParser不行,突然靈感一現換一個編碼方式讓它解析如何,或許是因為對gb2312的支援不夠好,於是將gb2312的NSData解碼成NSString然後再用utf-8編碼傳入NSXMLParser,結果所有的資料都不能解析了,這下我慌了,肯定是我使用的姿勢不對。趕緊去研究一下,發現NSXMLParser解析時會先讀取xml資料頭部的編碼資訊,encoding=”gb2312”我轉換為utf-8時沒有將這個改掉,所以解析會失敗,當我將gb2312改為utf-8後在用utf-8進行編碼,OK,所有問題都沒有了,至此字元上的問題全部解決了。

當你成功登入bbs後,你想瀏覽一些需要許可權的板塊或者你想在某個板塊發帖,伺服器該如何識別你是否已經登入了呢?這裡就需要有一個東西證明你已經登入,而這個東西就是cookie。

Cookie(複數形態Cookies),中文名稱為“小型文字檔案”或“小甜餅”[1],指某些網站為了辨別使用者身份而儲存在使用者本地終端(Client Side)上的資料(通常經過加密)

  • NSHTTPCookieStorage

不得不說蘋果的API寫的真的很好,對於cookie處理,蘋果有一個叫做NSHTTPCookieStorage,當你用NSURLRequest進行請求時,cookie會被自動儲存在NSHTTPCookieStorage中,而當你下一次訪問同一個網站時NSURLRequest會自動使用上次的儲存的cookie繼續去請求,你可以用下面的方法檢視所有儲存的cookie。

NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
for (NSHTTPCookie *cookie in [cookieStorage cookies]) {
   NSLog(@"%@", cookie);
}

但是這麼方便的東西,對於一個老的bbs卻不能使用。首先對於這個老的bbs伺服器來說他的cookie是直接放在response中的,並且是以xml格式呈現的,從狹義的角度上來說這不能算是cookie,NSURLRequest無法自動儲存這中cookie。
沒辦法那只有拿到那個xml資料然後自己解析手動設定cookie,然而悲催的是新增在NSHTTPCookieStorage中的cookie對於喻信的伺服器來說沒有效果。

  • HTTP Header

通過萬能的谷歌,我發現了另一種設定cookie的方式就是將cookie放在HTTP的請求頭中,像下面的程式碼一樣,而其中的cookie就是我拼接的一段字串。

[request setValue:cookie forHTTPHeaderField:@"Cookie"];

至此身份驗證的工作就搞定了。

而這cookie資訊由誰來持有呢?我想既然進行了封裝,那cookie也應該由這SDK來儲存,同時cookie資訊也在登入的成功後返回給呼叫者,防止呼叫者有其他用處。而在後面需要cookie的網路請求便可直接使用,如此封裝性更好。

URL中的特殊字元

在剛開始開發的時候並沒有考慮到這個問題,當應用測試時有人跟我說他的密碼中含有&符,登入時提示密碼錯誤,我當時真是醉了,居然有人在密碼中使用&(其實除了&,很多字元都不能直接放在URL裡),沒辦法不能不讓別人使用&,於是趕緊去網上搜索解決方案。
解決方法就是進行百分轉義,對於iOS開發總共有兩種方式:

  • 蘋果的介面

Core Foundation

CFURLCreateStringByAddingPercentEscapes

Foundation

- (nullable NSString *)stringByAddingPercentEncodingWithAllowedCharacters:(NSCharacterSet *)allowedCharacters

雖然他們屬於不同框架,但實質上他們做的是相同的事情就是對傳入字串中的指定字元進行轉換為百分轉義序列
對於第一個介面可以進行一定的封裝,然後可以方便的使用:

- (NSString *)percentEscapesString:(NSString *)string encoding:(CFStringEncoding)encoding {
    return (__bridge NSString *)CFURLCreateStringByAddingPercentEscapes(NULL, (CFStringRef)string, NULL, (CFStringRef) @"!*'();:@&=+$,/?%#[]", encoding);
}

而第二個介面是在iOS7及以上才可以使用,使用起來也很方便:

 - (NSString *)percentEscapesString:(NSString *)string {
    NSCharacterSet *charSet = [NSCharacterSet characterSetWithCharactersInString:@""];
    return [string stringByAddingPercentEncodingWithAllowedCharacters:charSet];
}

這樣會把整個string都轉換為百分轉義序列,後面傳入的引數是告訴他哪些字元可以不用轉義。

  • 手動實現

自己寫程式碼實現就是將字串中的非法字元用轉義後的字元去替換

+ URL 中+號表示空格:%2B
空格 URL中的空格可以用+號或者編碼:%20
/ 分隔目錄和子目錄:%2F
? 分隔實際的URL和引數:%3F
% 指定特殊字元:%25
# 表示書籤:%23
& URL 中指定的引數間的分隔符:%26
= URL 中指定引數的值:%3D

實質就是%加上符號對應的ASCII碼的十六進位制

正則表示式

對於這樣一個老的伺服器,它返回的資料都是直接用於顯示在網頁上的,並沒有對移動端進行優化,所以優化的工作都需要客戶端來做,為了匹配一些特定的內容,正則表示式當然是最好的選擇了。

正則表示式,又稱正規表示式、正規表示法、規則表示式、常規表示法(英語:Regular Expression,在程式碼中常簡寫為regex、regexp或RE),電腦科學的一個概念。正則表示式使用單個字串來描述、匹配一系列匹配某個句法規則的字串。在很多文字編輯器裡,正則表示式通常被用來檢索、替換那些匹配某個模式的文字。

正則表示式(regular expression)描述了一種字串匹配的模式,可以用來檢查一個串是否含有某種子串、將匹配的子串做替換或者從某個串中取出符合某個條件的子串等。

正則表示式由四個部分組成:普通字元、非列印字元、特殊字元和限定符。

. 匹配除換行符外的任意字元
\w 匹配字母或者數字的字元
\W 匹配任意不是字母或數字的字元
\s 匹配任意的空白符(空格、製表符、換行符)
\S 匹配任意不是空白符的字元
\f 匹配一個換頁符。等價於 \x0c 和 \cL。
\n 匹配一個換行符。等價於 \x0a 和 \cJ。
\r 匹配一個回車符。等價於 \x0d 和 \cM。
\d 匹配任意數字
\D 匹配任意非數字的字元
\b 匹配單詞的結尾或者開頭的字元
\B 匹配任意不是單詞結尾或開頭的字元
[^x] 匹配任意非x的字元。如[^[a-z]]匹配非小寫字母的任意字元
^ 匹配字串的開頭
$ 匹配字串的結尾
() 標記一個子表示式的開始和結束位置。
[] 標記一箇中括號表示式的開始和結束
| 兩項中選擇一個

* 匹配前面的子表示式重複任意次數
+ 匹配前面的子表示式重複一次以上的次數
? 匹配前面的子表示式一次或零次
{n} 匹配前面的子表示式重複n次
{n,} 匹配前面的子表示式重複n次或n次以上
{n,m} 匹配前面的子表示式重複最少n次最多m次

需要注意的是在iOS開發中,為了匹配正則表示式的特殊字元如:[ 則需要使用:\\[,因為要先將\轉義為真正的\,再用此\轉義後面的[字元,再比如如果要匹配字母或者數字的字元,不能直接用\w,應該用\\w。

友好的時間顯示

伺服器返回的時間都是字串,而且顯示的方式很不友好,並不適合用於展示,可以使用NSDateFormatter將字串轉換為NSDate,而使用NSDateFormatter需要設定date format,拿這個bbs返回的時間Sun Oct 2 16:07:49 2016為例其date format為@"EEE MMM d HH:mm:ss yyyy"

EEE表示簡寫的星期英文,MMM表示簡寫的月份英文,d表示的一位或二位的日期,dd表示二位的日期(即當日期小於10號時在前面補零),HH:mm:ss分別表示小時:分鐘:秒鐘,yyyy表示年份。

需要注意的是有時NSDateFormatter在將字串轉換為NSDate時可能會返回nil,有時可能是因為date format不正確,但有時即使正確也不行,對於這種情況有一種解決辦法就是設定locale identifier:

[_dateFormatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]];

而且需要有趣的是隻有設定為@"en_US_POSIX"才不會出問題。

下面就是將轉換的date以合適的方式展示出來:

- (NSString *)compareCurrentTime:(NSString *)str withDateFormatter:(NSDateFormatter *)formatter{
    NSDate *timeDate = [formatter dateFromString:str];

    NSTimeInterval  timeInterval = [timeDate timeIntervalSinceNow];
    timeInterval = -timeInterval;

    long temp = 0;
    NSString *result;
    if (timeInterval < 60) {
        result = [NSString stringWithFormat:@"剛剛"];
    }
    else if((temp = timeInterval/60) <60){
        result = [NSString stringWithFormat:@"%ld分鐘前",temp];
    }
    else if((temp = temp/60) <24){
        result = [NSString stringWithFormat:@"%ld小時前",temp];
    }
    else if((temp = temp/24) <30){
        result = [NSString stringWithFormat:@"%ld天前",temp];
    }
    else if((temp = temp/30) <12){
        result = [NSString stringWithFormat:@"%ld個月前",temp];
    }
    else{
        temp = temp/12;
        result = [NSString stringWithFormat:@"%ld年前",temp];
    }
    return  result;
}

這裡我直接將程式碼貼出,首先用NSDate提供的方法:timeIntervalSinceNow 獲取該時間與目前的時間的差值,該差值的單位是秒,利用該值在下面的一連串判斷中,轉換為符合常人閱讀習慣的時間,比如在一分鐘內就顯示剛剛,大於一分鐘小於一小時就顯示幾分鐘前,以此類推幾小時前,幾天前,幾個月前,幾年前,如此顯示更加友好。

結語

關於網路介面的封裝,大致就是這些,後面我還會帶來另外三篇部落格。這裡給出此應用的github地址:喻信星空