在iOS平臺上開發猜數游戲
這些天終于戒掉了星際爭霸2,開始學習iOS開發了。雖然還只是一知半解,但學了幾天后,覺得單視圖的iOS應用開發起來太輕松了,就忍不住想自己動手做點小玩意。
我也沒有什么好的創意,只是偶然看到猜數的游戲,覺得用選取器這個控件很適合,就決定做了。
雖然這個游戲大多數人都玩過,不過我還是介紹下規則吧:裁判從1到100以內隨機選擇一個整數,然后讓玩家猜測選擇的是什么數。每次猜測后,如果猜錯了,裁判會告訴玩家是猜大了還是猜小了,直到玩家猜出來。當然,用的次數越少就越好。如果用二分法的話,7次以內肯定能猜出來的。
而我要做的這個游戲中,裁判將由應用本身來擔當。玩家只要在選取器里選定一個數,然后點擊選擇按鈕,就會得知猜測的情況;同時選取器也自動更新,刪除不符合的數據,避免玩家選擇錯誤的數據。
接下來考慮界面。
它需要一個選取器來選數,需要一個選擇按鈕來確定所選的數,還需要一個重玩按鈕來重置游戲。而在通知方面,我覺得猜錯時可以直接用標簽來告知玩家,而在猜中時則彈出一個確認對話框比較好。
于是就開工了,運行Xcode,創建一個GuessNumber項目,在GuessNumberViewController.h里聲明控件變量。
- @interface GuessNumberViewController : UIViewController {
- UILabel *label;
- UIPickerView *picker;
- }
- @property (nonatomic, retain) IBOutlet UILabel *label;
- @property (nonatomic, retain) IBOutlet UIPickerView *picker;
- - (IBAction)chooseButtonPressed;
- - (IBAction)resetButtonPressed;
- @end
然后用Interface Builder畫出這樣一個界面出來,并與控件變量和行為連接起來:
再打開GuessNumberViewController.m,加上如下代碼,準備工作就做完了:
- @implementation GuessNumberViewController
- @synthesize label;
- @synthesize picker;
- - (void)viewDidUnload {
- self.label = nil;
- self.picker = nil;
- }
- - (void)dealloc {
- [label release];
- [picker release];
- [super dealloc];
- }
- @end
不過這個程序還只是個空殼,還得為它寫實現邏輯。先看UIPickerView。翻看SDK文檔,發現它并不能直接設置和顯示數據,而是用UIPickerViewDelegate和UIPickerViewDataSource這2個協議來完成的。簡化起見,我就沒創建一個模型類了,而是直接讓GuessNumberViewController來實現了:
- @interface GuessNumberViewController : UIViewController
- <UIPickerViewDelegate, UIPickerViewDataSource>
UIPickerViewDelegate主要需要實現這2個方法中的一個:
◆pickerView:titleForRow:forComponent:
◆pickerView:viewForRow:forComponent:reusingView:
前者是直接讓每行顯示一個字符串,而后者是每行顯示一個視圖,那自然是前者更方便了。
可是每行究竟要顯示什么數據呢?看上去可以用一個數組來保存現有的數,然后直接將行號作為數組的索引來獲取即可。
可讓我詫異的是創建數組時,居然沒有Python里range這樣方便的函數,于是得自己寫個方法來實現了:
- + (NSMutableArray *)makeArrayFrom:(NSInteger)begin to:(NSInteger)end {
- NSMutableArray *array = [[[NSMutableArray alloc]initWithCapacity:end - begin + 1]autorelease];
- for (NSInteger i = begin; i <= end; ++i) {
- [array addObject:[NSString stringWithFormat:@"%d", i]];
- }
- return array;
- }
這樣的實現讓我很擔心性能,覺得還不如C的數組好用。而且NSMutableArray為什么是NSArray的子類啊,有可能接收到一個NSArray對象,以為它是不變的,結果使用時卻莫名其妙地變了啊!有木有!難道接收到后每次都copy一份不影響性能嗎?可這貨畢竟是標準庫里的,你得逼自己接受它…(好戲還在后頭)
考慮到這樣創建很成問題,于是決定事先創建好一個完整的數組,然后每次要用時就copy一份。便給GuessNumberViewController加上2個私有變量NSMutableArray *pickerData和NSMutableArray *totalData,并在viewDidLoad方法中初始化它們。
- #define MAX_NUMBER 100
- - (void)viewDidLoad {
- self.totalData = [GuessNumberViewController makeArrayFrom:1 to:MAX_NUMBER];
- [self resetGame];
- [super viewDidLoad];
- }
- - (void)resetGame {
- NSMutableArray *totalDataCopy = [totalData mutableCopy];
- self.pickerData = totalDataCopy;
- [totalDataCopy release];
- }
- - (void)viewDidUnload {
- self.label = nil;
- self.picker = nil;
- self.pickerData = nil;
- self.totalData = nil;
- }
- - (void)dealloc {
- [label release];
- [picker release];
- [pickerData release];
- [totalData release];
- [super dealloc];
- }
數據準備好后,就可以實現– pickerView:titleForRow:forComponent:了:
- - (NSString *)pickerView:(UIPickerView *)pickerView
- titleForRow:(NSInteger)row
- forComponent:(NSInteger)component {
- return [pickerData objectAtIndex:row];
- }
再來看看UIPickerViewDataSource,這個協議也有2個方法:
◆numberOfComponentsInPickerView:
◆pickerView:numberOfRowsInComponent:
前者返回這個選擇器由幾個部分組成,這里我只需要一組即可;后者返回每個部分有多少行,其實也就是pickerData的長度而已:
- - (NSInteger)numberOfComponentsInPickerView:(UIPickerView *)pickerView {
- return 1;
- }
- - (NSInteger)pickerView:(UIPickerView *)pickerView
- numberOfRowsInComponent:(NSInteger)component {
- return [pickerData count];
- }
這時候NSArray又囧到我了。那個count方法說明了count不是個屬性,因此數組的長度并沒有用變量來保存。事后我也查了下,發現是以nil來判斷數組結尾的,這效率對長數組來說絕對是個災難。
現在選擇器的邏輯已經實現了,此時測試應該可以看到一個包含1~100的選擇器了,不過游戲的邏輯還并沒實現。
于是再給GuessNumberViewController加上NSInteger guessedTimes和NSInteger secretNumber,分別用于保存玩家猜的數和隨機數。
接著修改resetGame的邏輯來初始化它們:
- - (void)resetGame {
- guessedTimes = 0;
- secretNumber = arc4random() % MAX_NUMBER + 1;
- NSMutableArray *totalDataCopy = [totalData mutableCopy];
- self.pickerData = totalDataCopy;
- [totalDataCopy release];
- [picker reloadComponent:0];
- }
然后就是重點的chooseButtonPressed方法了,這個方法中需要刪除NSMutableArray的一部分,而且肯定是在頭或尾部刪除。查了下SDK文檔,適合這種批量刪除的方法有:
◆removeObjectsAtIndexes:
◆removeObjectsInArray:
◆removeObjectsInRange:
◆removeObjectsFromIndices:numIndices:
看上去很多是吧,別急,一個一個來看。
◆ removeObjectsAtIndexes:這個方法接收一個NSIndexSet參數。而NSIndexSet的創建和NSMutableArray差不多,也就是得循環生成,放棄。
◆removeObjectsInArray:就更直接了,直接接收一個NSArray參數。為了刪除一個數組的一部分而去創建另一個數組,有病啊?放棄。
◆removeObjectsInRange:接收一個NSRange參數。NSRange是一個結構,包括location和length這2個字段,看上去這就是我想要的。它實際上是用removeObjectAtIndex:來刪除對象的,所以自己寫循環來刪除會更快。
◆removeObjectsFromIndices:numIndices:已經被deprecated了,放棄??紤]到自己寫循環太麻煩,所以還是將就著使用– removeObjectsInRange:了,實現的算法我就不解釋了:
- - (void)alertWithMessage:(NSString *)message {
- UIAlertView *alert = [[UIAlertView alloc]
- initWithTitle:nil
- message:message
- delegate:nil
- cancelButtonTitle:@"確定"
- otherButtonTitles:nil];
- [alert show];
- [alert release];
- }
- - (IBAction)chooseButtonPressed {
- ++guessedTimes;
- NSInteger row = [picker selectedRowInComponent:0];
- NSString *selected = [pickerData objectAtIndex:row];
- NSInteger selectedNumber = [selected integerValue];
- NSInteger cutIndex;
- if (selectedNumber == secretNumber) {
- [self alertWithMessage:[NSString stringWithFormat:@"你猜中了!"]];
- [self resetGame];
- } else {
- cutIndex = [pickerData indexOfObject:[NSString stringWithFormat:@"%d", selectedNumber]];
- if (selectedNumber > secretNumber) {
- label.text = [NSString stringWithFormat:@"第%d次猜數,你猜得太大了。", guessedTimes];
- [pickerData removeObjectsInRange:NSMakeRange(cutIndex, [pickerData count] - cutIndex)];
- } else {
- label.text = [NSString stringWithFormat:@"第%d次猜數,你猜得太小了。", guessedTimes];
- [pickerData removeObjectsInRange:NSMakeRange(0, cutIndex + 1)];
- }
- }
- [picker reloadComponent:0];
- }
***別忘了resetButtonPressed,它只是調用resetGame方法而已:
- - (IBAction)resetButtonPressed {
- [self resetGame];
- }
現在就可以開玩了,效果如下:
看上去iOS開發的確很簡單,只是Objective-C惡心了一點而已。
不過別高興得太早,這篇文章還沒完成一半呢。玩了一會后我就立刻感到不爽了:從100個數里找到想要選擇的數太難了。
如果把選取器拆成2個部分,分別選擇十位和個位就會方便多了。不過這樣一來就不能猜1~100了,而應該猜0~99;MAX_NUMBER這個名字也不再合適,應該改成TOTAL_NUMBERS。
除此之外,如果繼續用數組實現的話,我得保存1個十位的數組和10個個位的數組,這樣維護起來太頭疼了。好在數據和UIPickerView并沒有綁定起來,可以自己實現取數邏輯,所以干脆保存當前最小和***的數,然后計算出十位和個位得了。
于是再給GuessNumberViewController加上NSInteger beginNumber和NSInteger endNumber這2個私有變量,然后開始修改取數邏輯:
- - (NSString *)pickerView:(UIPickerView *)pickerView
- titleForRow:(NSInteger)row
- forComponent:(NSInteger)component {
- if (component == 0) {
- return [NSString stringWithFormat:@"%d", beginNumber / 10 + row];
- }
- if ([picker selectedRowInComponent:0] == 0) {
- return [NSString stringWithFormat:@"%d", beginNumber % 10 + row];
- }
- return [NSString stringWithFormat:@"%d", row];
- }
- - (NSInteger)numberOfComponentsInPickerView:(UIPickerView *)pickerView {
- return 2;
- }
- - (NSInteger)pickerView:(UIPickerView *)pickerView
- numberOfRowsInComponent:(NSInteger)component {
- NSInteger unitsDigitOfBeginNumber = beginNumber % 10;
- NSInteger unitsDigitOfEndNumber = endNumber % 10;
- NSInteger tenthsDigitOfBeginNumber = beginNumber / 10;
- NSInteger tenthsDigitOfEndNumber = endNumber / 10;
- NSInteger differenceBetweenTenthsDigits = tenthsDigitOfEndNumber - tenthsDigitOfBeginNumber;
- NSInteger rowOfTenthsPlace;
- if (component == 0) {
- return differenceBetweenTenthsDigits + 1;
- }
- rowOfTenthsPlace = [picker selectedRowInComponent:0];
- if (rowOfTenthsPlace == 0) {
- if (differenceBetweenTenthsDigits == 0) {
- return unitsDigitOfEndNumber - unitsDigitOfBeginNumber + 1;
- }
- return 10 - unitsDigitOfBeginNumber;
- }
- if (rowOfTenthsPlace == differenceBetweenTenthsDigits) {
- return unitsDigitOfEndNumber + 1;
- }
- return 10;
- }
這里的邏輯比剛才復雜多了,不過慢慢看應該能看懂的,我也就不解釋了。接下來就是chooseButtonPressed的邏輯了,這次它只要更改beginNumber和endNumber,不需要維護數組了。
- - (IBAction)chooseButtonPressed {
- ++guessedTimes;
- NSInteger tenthsPlaceRow = [picker selectedRowInComponent:0];
- NSInteger unitsPlaceRow = [picker selectedRowInComponent:1];
- NSString *tenthsDigitString = [self pickerView:picker titleForRow:tenthsPlaceRow forComponent:0];
- NSString *unitsDigitString = [self pickerView:picker titleForRow:unitsPlaceRow forComponent:1];
- NSInteger tenthsDigit = [tenthsDigitString integerValue];
- NSInteger unitsDigit = [unitsDigitString integerValue];
- NSInteger selectedNumber = tenthsDigit * 10 + unitsDigit;
- if (selectedNumber == secretNumber) {
- [self alertWithMessage:[NSString stringWithFormat:@"你猜中了!"]];
- [self resetGame];
- } else if (selectedNumber > secretNumber) {
- statusLabel.text = [NSString stringWithFormat:@"第%d次猜數,你猜得太大了。", guessedTimes];
- endNumber = selectedNumber - 1;
- } else {
- statusLabel.text = [NSString stringWithFormat:@"第%d次猜數,你猜得太小了。", guessedTimes];
- beginNumber = selectedNumber + 1;
- }
- [picker reloadAllComponents];
- }
而重置的代碼也得改改:
- - (void)resetGame {
- guessedTimes = 0;
- secretNumber = arc4random() % TOTAL_NUMBERS;
- beginNumber = 0;
- endNumber = TOTAL_NUMBERS - 1;
- label.text = @"";
- [picker reloadAllComponents];
- }
現在再運行一下,會發現一堆bug。最嚴重的一個就是選擇的十位數改變時,個位不會相應地改變。好在UIPickerViewDelegate協議提供了pickerView:didSelectRow:inComponent:方法,只要在十位改變時,重新載入個位的數據即可:
- - (void)pickerView:(UIPickerView *)pickerView didSelectRow:(NSInteger)row inComponent:(NSInteger)component {
- if (component == 0) {
- [picker reloadComponent:1];
- }
- }
此外就是按了選擇按鈕后,個位數有時候會超過9。調試了一番后我發現是[picker reloadAllComponents]這行代碼的問題,它會先reload部件1,再reload部件0,而我的代碼邏輯中,部件1的數據(個位)是依賴于部件0(十位)的,于是就出錯了。解決辦法很簡單,依次reload即可:
- [picker reloadComponent:0];
- [picker reloadComponent:1];
現在bug是搞定了,可是有個問題不太爽:游戲結束時彈出確認對話框,我還沒點確定,游戲就已經重置了,有點不符合習慣。好在UIAlertView有個delegate屬性,它的– alertView:didDismissWithButtonIndex:方法就可以延緩重置時機了。于是老辦法,給GuessNumberViewController實現UIAlertViewDelegate協議,然后進行如下修改:
- - (void)alertWithMessage:(NSString *)message {
- UIAlertView *alert = [[UIAlertView alloc]
- initWithTitle:nil
- message:message
- delegate:self
- cancelButtonTitle:@"再玩一次"
- otherButtonTitles:nil];
- [alert show];
- [alert release];
- }
- - (void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex {
- [self resetGame];
- }
再把調用alertWithMessage下一行的[self resetGame]刪掉即可。接下來還有什么問題呢?結束時的提示太無聊了,沒動力繼續玩下去,于是再改改chooseButtonPressed:
- if (selectedNumber == secretNumber) {
- if (guessedTimes < 4) {
- [self alertWithMessage:[NSString stringWithFormat:@"哼,人家才不告訴你%d次就猜中是很稀罕的呢!", guessedTimes]];
- } else if (guessedTimes < 8) {
- [self alertWithMessage:[NSString stringWithFormat:@"別多想啦,%d次猜中是很正常的啦,繼續加油吧!", guessedTimes]];
- } else {
- [self alertWithMessage:[NSString stringWithFormat:@"笨蛋,%d次才猜中,你有沒有用心在猜?。?quot;, guessedTimes]];
- }
- }
現在如何呢?還有個很大的問題——數字是左對齊的,應該弄成居中對齊的啊!可是翻了一遍文檔,確實沒找到哪里可以設置對齊屬性。在網上搜了一陣,發現需要自己創建UILabel作為每行的視圖,然后設置UILabel的對齊屬性:
- - (UIView *)pickerView:(UIPickerView *)pickerView
- viewForRow:(NSInteger)row
- forComponent:(NSInteger)component
- reusingView:(UIView *)view {
- UILabel *digitLabel;
- if (view) {
- digitLabel = (PickerViewLabel *)view;
- } else {
- digitLabel = [[[UILabel alloc] initWithFrame:CGRectMake(0.0f, 0.0f, [pickerView rowSizeForComponent:component].width, [pickerView rowSizeForComponent:component].height)] autorelease];
- }
- NSString *title;
- if (component == 0) {
- title = [NSString stringWithFormat:@"%d", beginNumber / 10 + row];
- } else if ([picker selectedRowInComponent:0] == 0) {
- title = [NSString stringWithFormat:@"%d", beginNumber % 10 + row];
- } else {
- title = [NSString stringWithFormat:@"%d", row];
- }
- digitLabel.text = title;
- digitLabel.textAlignment = UITextAlignmentCenter;
- return digitLabel;
- }
這樣一來就有很多label了,所以原來的label就改名為statusLabel以作區分吧。而chooseButtonPressed中也需要更改數值的獲取方法
- NSString *tenthsDigitString = ((UILabel *)[picker viewForRow:tenthsPlaceRow forComponent:0]).text;
- NSString *unitsDigitString = ((UILabel *)[picker viewForRow:unitsPlaceRow forComponent:1]).text;
改完后立刻發現背景不對勁,變成白色的了。于是去掉digitLabel的背景色,順便將文本設為粗體:
- digitLabel.backgroundColor = [UIColor clearColor];
- digitLabel.font = [UIFont boldSystemFontOfSize:24.0];
現在是否OK了呢?不,你會發現點擊一行時,還會出現藍色的高亮背景。這現象是怎么產生的呢?原來UIPickerView是用UITableView實現的,而UITableCell在選中時默認會高亮。
簡單的解決辦法就是設置digitLabel.userInteractionEnabled = YES,這樣一來digitLabel就攔截了點擊事件,不會傳遞給UITableCell了。
可是這個辦法仍然有問題:默認的UIPickerView在點擊一行時,會滾動定位到這行;而攔截了事件后,自動滾動的功能也就沒了。
于是更好的辦法就是在點擊時獲取這個UITableCell,將它設為不高亮。然而坑爹的是UITableCell屬于私有API,SDK文檔里找不到資料,調用它的方法得使用performSelector方法。
為此需要自定義一個PickerViewLabel類:
- @interface PickerViewLabel : UILabel {
- }
- @end
- @implementation PickerViewLabel
- - (void)didMoveToSuperview {
- UIView *superview = [self superview];
- if ([superview respondsToSelector:@selector(setShowSelection:)]) {
- [superview performSelector:@selector(setShowSelection:) withObject:NO];
- }
- }
- @end
這個didMoveToSuperview方法的名字很囧,它其實是在父視圖改變時被調用的。
因為高亮也是改變的一種,所以它就會被調用了。拿到父視圖后并不能判斷它是否是UITableCell類的對象,因為我們沒有UITableCell的聲明頭文件。于是通過respondsToSelector判斷它是否能調用setShowSelection:,再通過performSelector調用這個方法。
現在總該可以了吧?不,還有個問題:重新載入UIPickerView的組件時,有時會停在莫名其妙的一行上。于是在resetGame的末尾加上:
- [picker selectRow:0 inComponent:0 animated:NO];
- [picker selectRow:0 inComponent:1 animated:NO];
同時也加在chooseButtonPressed的第三種里:
- // ...
- } else {
- statusLabel.text = [NSString stringWithFormat:@"第%d次猜數,你猜得太小了。", guessedTimes];
- beginNumber = selectedNumber + 1;
- [picker selectRow:0 inComponent:0 animated:NO];
- [picker selectRow:0 inComponent:1 animated:NO];
- }
好了,最終效果如下:
雖然按鈕的樣式還能改改,不過必須用到背景圖,我就懶得找圖了。至于還能改進的地方,我覺得就是太不耐玩了。如果加入聯機對戰模式或許就大不一樣了,2個玩家可以比拼誰先猜出來,就可以獲得更多樂趣了。當然,這玩意犯不著搞那么復雜的東東出來,反正也就自己做著玩而已。***想說的是,iOS開發難在細節。它看上去很簡單,但很多細枝末節的方面若想精益求精,就不得不耗費苦心。一是標準庫并不好用,二是SDK設計得并不周到,三是你不得不用到私有API。