IOS的後臺任務

轉自:http://blog.csdn.net/chowpan/article/details/22417247

翻譯自:http://www.raywenderlich.com/29948/backgrounding-for-ios


(代碼部分若亂碼,請移步原鏈接拷貝)

自ios4開始,用戶點擊home按鈕時,你可以將app設計爲掛起狀態。app在內存中,除非用戶再次返回到app,否則該app暫停運行。都是這種情況嗎?


當然不是,在一些例外的情況下,app仍然可以在後臺保持運行。這篇文章將介紹如何以及何時應用(幾乎)所有這些後臺操作功能。


應用後臺運行模式實際上有很嚴格的限制條件,在ios上實現真正的多任務上,這並非一個奇特的解決辦法。app退回後臺時,比如用戶切換到了另一個app,更多的是完全被掛起。

允許app在後臺仍然運行的情況僅限於以下幾種:

1,播放音頻文件(playing audio)

2,獲取定位更新(getting location updates)

3,雜誌app中下載新的期刊(downloading new issues for newsstand apps)

4,VoIP 呼叫(handing VoIP calls)


假如你的app沒有用到如上任一功能,那麼非常不幸運。需要特別注意的是:針對所有的app,在它們真正被掛起之前,有最多10分鐘的時間去完成其正在執行的任務。


所以後臺模式可能並非如你所願,但仍請看官細品!


接下來你將學習到,在IOS中5種基本的可用的後臺模式。本章你創建的項目是一個基於tabbed的簡單應用,每個tab展示了上述5種基本後臺模式效果之一,即從‘播放音頻’開始到‘接聽Voice - over - IP’鏈接。


贅言不絮,開始正文。


初探--”後臺模式“

在深入探討之前,下面預覽下這5種基本後臺模式:

1,音頻播放:在後臺app依然可以播放/錄製音頻。

2,實時接收定位更新:app依然可以獲取設備位置更新的回調。

3,執行一個有限時長的任務:通用的情況,在限定的時間內,app可以運行任意作用的代碼。

4,雜誌下載:對於雜誌類app,允許其在後臺下載更新的內容。

5,提供VoIP服務:允許app在後臺運行任意作用的代碼。當然前提是你的app必須提供了VoIP服務。


本文接下來將依次介紹上述5種後臺模式,你若僅對其中的一種或幾種模式感興趣,可以選擇閱讀。


首先下載本文介紹的項目,本文demo下載鏈接:sample project   ,你也可以follow該項目的GitHub頁面 ,其中有詳盡的項目創建過程,儘管本文着重介紹的是後臺模式的操作。

好消息:用戶接口已經爲你預先配置好了,這更有利於專注後臺模式的學習。


運行項目,效果如下:


上面的tabs將是本節闡述的引導圖。首先我們將進行音頻後臺模式。


附:爲了達到測試的最優效果,你英愛在真機上運行本程序,因爲有些後臺功能在模擬器上不能闡釋的那麼完善(甚至完全沒有效果)。


音頻播放:

在IOS上有若干方法進行音頻的播放,這些方法中的多數均要求繼承回調來提供後續的音頻數據進行播放。回調(如委託方法)即是在適當時間進行某種操作,使用音頻流填充緩衝區。

假如打算以數據流方式來進行音頻播放,你可以開啓一個網絡連接,並用該連接回調驚醒持續的數據流接受。


當使能音頻後臺模式播放時,既是app已經在後臺,即不是當前活躍的app,IOS仍然可以調用上述回調方法---就是這樣,這是本文介紹的後臺模式中的4個之一,音頻後臺模式幾乎是自動完成的,你僅需要激活它,並提供合適的基礎操作即可。


僅當你的app是真的提供給用戶音頻播放功能,你才能使用音頻後臺模式。若我們抱有僥倖心理,爲了獲得CPU更多時間而利用該模式播放一段無聲的音頻,apple將會拒絕此類app。


本節,你將添加音頻播放到項目,並開啓後臺模式演示其效果。


爲了實現音頻播放,首先需要學習AV Foundation,打開 TBFirstViewController.m文件,添加頭文件:

#import <AVFoundation/AVFoundation.h>

在viewDidLoad中,添加如下代碼:

 // Set AVAudioSession
    NSError *sessionError = nil;
    [[AVAudioSession sharedInstance] setDelegate:self];
    [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayAndRecord error:&sessionError];
 
    // Change the default output audio route
    UInt32 doChangeDefaultRoute = 1;
    AudioSessionSetProperty(kAudioSessionProperty_OverrideCategoryDefaultToSpeaker,
      sizeof(doChangeDefaultRoute), &doChangeDefaultRoute);

代碼初始化了audio session對象,並確定用揚聲器來播放而非聽筒。

以下變量用來跟蹤播放進程:

@property (nonatomic, strong) AVQueuePlayer *player;
@property (nonatomic, strong) id timeObserver;

聲明在如下位置:

@interface TBFirstViewController ()
 
// Insert code here
 
@end

項目開始包含了一個音頻文件,來自favorit rotalty-free music websites 。你可以免費使用其中的音頻文件,所有的文件由Kevin Macleod 提供,所以,感謝Kevin!


在IOS上播放音樂,一種最簡單的方法之一即是應用AV Foundations AVPlayer。 故我們的實例將會使用一個AVPlayer的子類叫做AVQueuePlayer 。AVQueuePlayer允許我們設置一個AVPlayerItems隊列,用來依次並自動的播放音頻文件。


在viewDidLoad結尾:


 NSArray *queue = @[
    [AVPlayerItem playerItemWithURL:[[NSBundle mainBundle] URLForResource:@"IronBacon" withExtension:@"mp3"]],
    [AVPlayerItem playerItemWithURL:[[NSBundle mainBundle] URLForResource:@"FeelinGood" withExtension:@"mp3"]],
    [AVPlayerItem playerItemWithURL:[[NSBundle mainBundle] URLForResource:@"WhatYouWant" withExtension:@"mp3"]]];
 
    self.player = [[AVQueuePlayer alloc] initWithItems:queue];
    self.player.actionAtItemEnd = AVPlayerActionAtItemEndAdvance;

代碼首先創建了一個含有AVPlayerItems對象的數組,接着以該數組初始化AVQueuePlayer對象,並設置其爲連續播放。


在播放進程中,爲了更新音樂名字,你需要註冊監聽player的currentItem屬性,該功能代碼添加至viewDidLoad的結尾:

 [self.player addObserver:self
                  forKeyPath:@"currentItem"
                     options:NSKeyValueObservingOptionNew
                     context:nil];

當player的currentItem屬性變化時,將會回調監聽事件。添加監聽事件到viewDidLoad下面:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if ([keyPath isEqualToString:@"currentItem"])
    {
        AVPlayerItem *item = ((AVPlayer *)object).currentItem;
        self.lblMusicName.text = ((AVURLAsset*)item.asset).URL.pathComponents.lastObject;
        NSLog(@"New music name: %@", self.lblMusicName.text);
    }
}

當監聽方法被調用時,首先應該確定的是更新的屬性是我們需要關注的。但在本例中,因爲僅監聽一個屬性,故判斷語句不是必須的。但是判斷檢測是一個很好的習慣,也是以防後期會添加另外的屬性監聽。如是‘currentItem’屬性變化,測更新lb1MusicName標籤。


你或許需要更新當前播放條目的已播時間,實現的最好方法是利用:addPeriodicTimeObserverForInterval:queue:usingBlock:方法,在指定的queue中提供回調block。

在viewDidLoad結尾添加:

void (^observerBlock)(CMTime time) = ^(CMTime time) {
        NSString *timeString = [NSString stringWithFormat:@"%02.2f", (float)time.value / (float)time.timescale];
        if ([[UIApplication sharedApplication] applicationState] == UIApplicationStateActive) {
            self.lblMusicTime.text = timeString;
        } else {
            NSLog(@"App is backgrounded. Time is: %@", timeString);
        }
    };
 
    self.timeObserver = [self.player addPeriodicTimeObserverForInterval:CMTimeMake(10, 1000)
                                                                  queue:dispatch_get_main_queue()
                                                             usingBlock:observerBlock];

首先創建一個block,當時間更新時,它將被調用。假如你對block不熟悉,可以閱讀:How to User Blocks in IOS5 tutorial 。該block基於app的狀態,創建一個顯示音樂播放時間的字串。

在此之後,便是調用-(id)addPeriodicTimeObserverForInterval:(CMTime)interval queue:(dispatch_queue_t)queue usingBlock:(void(^)(CMTime time))block 開始獲取更新信息。


附:關於app的狀態

你的app始終處在以下5種狀態的其中之一,概要如下:

Not running:app在啓動前在此狀態

Active:一旦app開啓,即此狀態

Inactive:app在運行時有事件中斷它的執行,比如一個電話呼叫到來,它將進入該狀態。inactive意味着app仍然在前臺運行但卻不接收事件。

Backgrounded:此狀態,app不在前臺,但其仍能執行代碼

Suspended:當不執行代碼時,app即進入該狀態


若你希望瞭解上述狀態的更詳盡信息,請移步apple官網:App States and Multitasking


通過調用[[UIApplication sharedApplication]applicationState]來檢測app的狀態,不過要注意的是你僅可以獲取以下3種狀態之一:

UIApplicationStateActive;UIApplicationStateInactive;UIApplicationStateBackground。suspended和not running很明顯不可能在運行代碼期間檢測到,所以沒有它們無對應的值。


返回代碼,加入app處在active狀態,你需要更新label標籤,否則,將更新信息打印到控制檯即可。在後臺狀態時仍需要更新label標籤,但現在需要演示的是app進入後臺後,仍能接收回調。


現在要做的是完成play/pause按鈕的工作,即實現didTapPlayPause方法,添加該方法至TBFirstViewController.m中:

- (IBAction)didTapPlayPause:(id)sender
{
    self.btnPlayPause.selected = !self.btnPlayPause.selected;
    if (self.btnPlayPause.selected)
    {
        [self.player play];
    }
    else
    {
        [self.player pause];
    }
}

所有代碼完成,運行之:


點擊‘player’按鈕,音樂將起,good。


現在我們來測試下後臺模式的工作情況,點擊home(模擬器:Cmd+shift+H)後,但是此刻音樂也隨之停止了。爲什麼?因爲還有關鍵的一塊沒有完成。

大多數後臺模式(除了3,有限時長任務外),你需要在info.plist中添加一個key,來聲明該app在後臺時要運行代碼。


返回Xcode,操作以下:

1,點擊項目

2,點擊info

3,點擊“+”

4,在出現的列表中,選擇‘Required Background Modes’


當選擇了4中的條目後,Xcode將會在該條目下創建一個數組,並含有一個空條目。點擊該子條目右側,並選擇‘App plays audio’。在顯示的列表中,課餘ikandao所有本文介紹的後臺模式,當然也包含一些基於某些硬件的條目信息。


再次運行項目,播放音樂,然後點擊‘Home’鍵,app進入後臺,但音樂照就播放了。


假如仍然沒有出現上述效果,可能是因爲你用的是模擬器,試着用真機測試,應該沒問題。

你也可以通過查看在Xcode控制檯的時間更新來證明在後臺app仍是工作的。

GitHub上關於本後臺模式的演示項目:BackgroundMusic


2,實時接收定位更新


在定位後臺模式中,即便app處在後臺模式,它仍能根據定位委託事件來接收用戶的位置更新信息。

需要提醒的是:僅當你的app確實能夠根據後臺定位來提供有益於用戶的價值,纔可使用該模式。否則,你用了該模式,但對apple看來,用戶毫無獲益,你的app將會被拒。有時apple也會要求你在app添加一段警告,即告知用戶你的app會增加電池的使用量。


演示項目的第二個tab就是關於定位更新的。打開TBSecondViewController.m文件,將以下聲明

@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, strong) NSMutableArray *locations;

添加在:

@interface TBSecondViewController ()
 
// add code here
 
@end

中。

CLLocationManager用來獲取設備的定位更新信息。

loactions數組用來存貯多個將被標記到map上的位置信息。


在viewDidLoad末尾添加:


  self.locations = [[NSMutableArray alloc] init];
        self.locationManager = [[CLLocationManager alloc] init];
        self.locationManager.desiredAccuracy = kCLLocationAccuracyBest;
        self.locationManager.delegate = self;

將保存位置更新信息的數組初始化。

初始化CLLocationManager對象,並設置其精度(這個可以根據需要設置其值,接下來將會介紹更多的精度設置)屬性。


完成方法:

- (IBAction)accuracyChanged:(id)sender
{
    const CLLocationAccuracy accuracyValues[] = {
        kCLLocationAccuracyBestForNavigation,
        kCLLocationAccuracyBest,
        kCLLocationAccuracyNearestTenMeters,
        kCLLocationAccuracyHundredMeters,
        kCLLocationAccuracyKilometer,
        kCLLocationAccuracyThreeKilometers};
 
    self.locationManager.desiredAccuracy = accuracyValues[self.segmentAccuracy.selectedSegmentIndex];
}

accuracyValue數組包含CLLocationManager的desireAccuracy屬性中所有可能的值。


或許你認爲這是很笨拙的方法,爲什麼不能將accuracy屬性一直設置爲最高精度吶?這是因爲考慮到電量的消耗。精度越低,電量使用越少。


所以綜上,當你的app不需要精度太高時,儘量選擇精度和你需求的最接近的值即可。你也可以根據需要隨時修改它。


不考慮desiredAccuracy的值distanceFilter時,還有一個屬性用來控制app多久進行一次定位更新。當設備移動達到一定的距離時(m),該屬性來控制locationManager接收定位更新。爲了節省電量,該屬性應該在滿足要求情況下,越高越好。


添加如下代碼來開始獲取/暫停定位更新;

- (IBAction)enabledStateChanged:(id)sender
{
    if (self.switchEnabled.on)
    {
        [self.locationManager startUpdatingLocation];
    }
    else
    {
        [self.locationManager stopUpdatingLocation];
    }
}

在xib文件中有UISwitch控件,並鏈接至該方法,以開啓/關閉定位追蹤。


實現CLLocationManagerDelegate方法,來獲取更新的信息:

#pragma mark - CLLocationManagerDelegate
 
/*
 *  locationManager:didUpdateToLocation:fromLocation:
 *
 *  Discussion:
 *    Invoked when a new location is available. oldLocation may be nil if there is no previous location
 *    available.
 *
 *    This method is deprecated. If locationManager:didUpdateLocations: is
 *    implemented, this method will not be called.
 */
- (void)locationManager:(CLLocationManager *)manager
    didUpdateToLocation:(CLLocation *)newLocation
           fromLocation:(CLLocation *)oldLocation
{
    // Add another annotation to the map.
    MKPointAnnotation *annotation = [[MKPointAnnotation alloc] init];
    annotation.coordinate = newLocation.coordinate;
    [self.map addAnnotation:annotation];
 
    // Also add to our map so we can remove old values later
    [self.locations addObject:annotation];
 
    // Remove values if the array is too big
    while (self.locations.count > 100)
    {
        annotation = [self.locations objectAtIndex:0];
        [self.locations removeObjectAtIndex:0];
 
        // Also remove from the map
        [self.map removeAnnotation:annotation];
    }
 
    if (UIApplication.sharedApplication.applicationState == UIApplicationStateActive)
    {
        // determine the region the points span so we can update our map's zoom.
        double maxLat = -91;
        double minLat =  91;
        double maxLon = -181;
        double minLon =  181;
 
        for (MKPointAnnotation *annotation in self.locations)
        {
            CLLocationCoordinate2D coordinate = annotation.coordinate;
 
            if (coordinate.latitude > maxLat)
                maxLat = coordinate.latitude;
            if (coordinate.latitude < minLat)
                minLat = coordinate.latitude;
 
            if (coordinate.longitude > maxLon)
                maxLon = coordinate.longitude;
            if (coordinate.longitude < minLon)
                minLon = coordinate.longitude;
        }
 
        MKCoordinateRegion region;
        region.span.latitudeDelta  = (maxLat +  90) - (minLat +  90);
        region.span.longitudeDelta = (maxLon + 180) - (minLon + 180);
 
        // the center point is the average of the max and mins
        region.center.latitude  = minLat + region.span.latitudeDelta / 2;
        region.center.longitude = minLon + region.span.longitudeDelta / 2;
 
        // Set the region of the map.
        [self.map setRegion:region animated:YES];
    }
    else
    {
        NSLog(@"App is backgrounded. New location is %@", newLocation);
    }
}

本文並非關於專注位置相關技術,故簡要說明:當app是active時,將依據位置信息更新地圖;app是background時,只將位置更新信息打印到Xcode的控制檯上。


接下來我們來修改info.plist,以支持後臺模式,別重蹈覆轍。添加另外一個條目(‘app register for location updates’)至數組,app便會在進入後臺後,仍能繼續接收位置更新信息。


運行項目,並切換至第二個tab,講switch控件置爲‘ON’狀態。


當app首次運行時,將會彈出一個對話框提示是否允許該app訪問location定位服務。點擊ok並移動設備,你應該看到位置更新情況,在模擬器上也可以模擬。

大致效果如下:


點擊Home鍵,將app置於後臺,移動設備,可以看到在控制檯上輸出更新信息。片刻,將app置於前臺,將會看到所有的地圖標註pins都更新爲後臺獲取的新的位置。


假如應用模擬器,可以模擬移動效果,工具如圖:


將location設置爲Freeway Drive,點擊home鍵。當你定位到一加州高速路上時,將會看到控制檯上打印出來的信息。


2013-03-07 22:31:11.667 TheBackgrounder[52611:c07] App is backgrounded. New location is <+37.33500926,-122.03272188> +/- 5.00m (speed 7.74 mps / course 246.09) @ 3/7/13, 10:31:11 PM Eastern Daylight Time
2013-03-07 22:31:12.670 TheBackgrounder[52611:c07] App is backgrounded. New location is <+37.33497737,-122.03281282> +/- 5.00m (speed 9.18 mps / course 251.37) @ 3/7/13, 10:31:12 PM Eastern Daylight Time
2013-03-07 22:31:13.669 TheBackgrounder[52611:c07] App is backgrounded. New location is <+37.33494812,-122.03292120> +/- 5.00m (speed 10.78 mps / course 251.72) @ 3/7/13, 10:31:13 PM Eastern Daylight Time
2013-03-07 22:31:14.658 TheBackgrounder[52611:c07] App is backgrounded. New location is <+37.33492222,-122.03304215> +/- 5.00m (speed 12.11 mps / course 254.18) @ 3/7/13, 10:31:14 PM Eastern Daylight Time

有關該模式的演示代碼:BackgroundLocation 。


第三個tab即第三種後臺模式

有限時長的後臺任務(performing finite-Length Tasks --or,whatever)

這個模式官方稱謂叫‘Executing a finite-Length Task in the background’,簡稱爲‘whatever’。


從技術上講,這不是一個後臺模式,因爲你不需要在info.plist文件中聲明你要用這種後臺模式。而是依據一個API,當app進入後臺後,在有限的時間內允許執行任意的代碼。


比如,你可以應用此模式完成一個上傳或者下載任務。形如你創建了一個圖片分享app(或者其它),用戶選擇了一個圖片,並切離該app,可能沒有時間去將圖片傳至服務器上,但是利用此模式的API,你將獲取一定CPU時間來完成上傳。


上述只是一例,該後臺執行代碼是任意的,你可以利用此API去做任何事。執行很複雜的運算,給圖片添加過濾器,渲染一個3D等等。但是需要注意的是你僅獲得有限的時間,而非無限制的。

app進入後臺後,這個執行時間有多長,將取決於IOS。獲取的時長不確定,但是你可以一直檢測着UIApplication的backgroundTimeRemaning屬性,它講告訴你還剩有多久。


普遍但非經API文檔說明,你可能有10分鐘的時間--所以不要依賴這個數字,你也可能只獲得5分鐘甚至於5秒都有可能,故app需要時刻做好任何可能的打算。


下面是一個普通任務:廣爲熟知的斐波拉切數列FibonacciSequence。下面將演示在後臺進行運算。


打開TBThirdViewController.m文件,添加屬性:

@property (nonatomic, strong) NSDecimalNumber *previous;
@property (nonatomic, strong) NSDecimalNumber *current;
@property (nonatomic) NSUInteger position;
@property (nonatomic, strong) NSTimer *updateTimer;
@property (nonatomic) UIBackgroundTaskIdentifier backgroundTask;

@interface TBThirdViewController ()
 
// add code here
 
@end


中。

NSDecimalNumber將持有數列中2個上次的值,NSDecimalNumber對象可保存非常大的數,所以此處應用非常適合。

position計數器,在序列中告知你當前數的位置。


應用updateTimer演示持續計算過程,並能減緩計算過程,以方便觀察。


加如下代碼至viewDidLoad結尾:

self.backgroundTask = UIBackgroundTaskInvalid;

關鍵:

- (IBAction)didTapPlayPause:(id)sender
{
    self.btnPlayPause.selected = !self.btnPlayPause.selected;
    if (self.btnPlayPause.selected)
    {
        self.previous = [NSDecimalNumber one];
        self.current  = [NSDecimalNumber one];
        self.position = 1;
 
        self.updateTimer = [NSTimer scheduledTimerWithTimeInterval:0.5
                                                            target:self
                                                          selector:@selector(calculateNextNumber)
                                                          userInfo:nil
                                                           repeats:YES];
 
        self.backgroundTask = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:^{
            NSLog(@"Background handler called. Not running background tasks anymore.");
            [[UIApplication sharedApplication] endBackgroundTask:self.backgroundTask];
            self.backgroundTask = UIBackgroundTaskInvalid;
        }];
    }
    else
    {
        [self.updateTimer invalidate];
        self.updateTimer = nil;
        if (self.backgroundTask != UIBackgroundTaskInvalid)
        {
            [[UIApplication sharedApplication] endBackgroundTask:self.backgroundTask];
            self.backgroundTask = UIBackgroundTaskInvalid;
        }
    }
}

詳解上述代碼,探討如何使用API的。

通過切換按鈕的selected屬性來決定是否進行計算,或者準備開始或者結束。


首先設置數列,接着創建NSTimer,並開啓,使之每隔0.5秒執行一次calculateNextNumber方法。

下面即是最精要代碼:調用beginBackgroundTaskWithExpirationHander:方法,告知IOS一旦app進入後臺,將需要更多的時間來完成某些操作。調用之後,app若進入後臺,仍獲取部分CPU時長,直至調用endBackgroudTask:方法。


當然,如你不調用endBackgroudTask:方法,經過一段的後臺時長,IOS將調用beginBackgroundTaskWithExpirationHander中定義的Block,讓你結束正在執行的代碼,所以此處是調用endBackgroudTask的好地方,告知IOS已經完成相關功能。若你不理會此方法的調用,依然執行代碼的話,app將會被強制終止。


if之後使timer無效(invalidates):並調用endBackgroudTask:方法告知IOS不再需要額外的後臺時間。

每次調用beginBackgroundTaskWithExpirationHander時調用endBackgroudTask:是很重要的。假如調用beginBackgroundTaskWithExpirationHander兩次,而只調用endBackgroudTask一次,app將仍請求有CPU時間,直到用第二次backgroundtask的值再一次調用endBackgroudTask爲止。這也是需要background值的原因。


補全代碼:

- (void)calculateNextNumber
{
    NSDecimalNumber *result = [self.current decimalNumberByAdding:self.previous];
 
    if ([result compare:[NSDecimalNumber decimalNumberWithMantissa:1 exponent:40 isNegative:NO]] == NSOrderedAscending)
    {
        self.previous = self.current;
        self.current  = result;
        self.position++;
    }
    else
    {
        // This is just too much.... Let's start over.
        self.previous = [NSDecimalNumber one];
        self.current  = [NSDecimalNumber one];
        self.position = 1;
    }
 
    NSString *currentResultLabel = [NSString stringWithFormat:@"Position %d = %@", self.position, self.current];
    if (UIApplication.sharedApplication.applicationState == UIApplicationStateActive)
    {
        self.txtResult.text = currentResultLabel;
    }
    else
    {
        NSLog(@"App is backgrounded. Next number = %@", currentResultLabel);
        NSLog(@"Background time remaining = %.1f seconds", [UIApplication sharedApplication].backgroundTimeRemaining);
    }
}

此處比較有意思的是backgroundTimeRemaining的值。此代碼僅當調用beginBackgroundTaskWithExpirationHander的block時才停止。

運行項目,並切換至第三個tab。


點擊開始,查看app計算情況。點擊home鍵,查看控制檯輸出情況,you should see the app still updating the numbers while the time remaining goes down。


在大多數情況下,這個後臺時長始以600s(10分鐘)到最低5s。當時長達到5s即過期時(也可能是其它時長),過期block將被調用,app停止輸出信息。此時返回app,timer將重新運行。


在上面代碼中僅有一個bug,由此可以解釋什麼是‘後臺通知’。

假設將app置於後臺,知道過期,app將調用過期block,並執行endBackgroudTask。結束後臺時間需求。


此刻再次重回app,timer將繼續fire。但再次置於後臺,將不會再有後臺時長。這是爲什麼?因爲在過期後和再次進入後臺時沒有再次調用beginBackgroundTaskWithExpirationHander。


有多種解決方式來修正此bug,其中之一是利用app狀態更改監聽來更正之。


2種獲取狀態更改通知:

1,通過main app delegate方法。

2,監聽IOS發送來的監聽事件。


UIApplicationWillResignActiveNotification和applicationWillResignActive:當app將要置爲inactive狀態時,前者將被sent,後者將被調用。此時,app尚未進入後臺--仍是前臺app但不會接收任何UI事件。


UIApplicationDidEnterBackgroundNotification和applicationDidEnterBackground:app置爲後臺時將被sent和調用。此時,app不再active,並且這是最後運行代碼的機會。這也是調用beginBackgroundTaskWithExpirationHander的執行時間,若想獲得更多的CPU時長的話。


UIapplicationWillEnterForegroundNotification和applicationWillEnterForeground:app將至active狀態時將被sent和調用。app此時仍在後臺,但你已經可以開始你要做的代碼了。也是調用endBackgroudTask的好時刻,若是在進入後臺時調用beginBackgroundTaskWithExpirationHander的話。


UIApplicationDidBecomeActiveNotification和applicationDidBecomeActive:在上面的事件發生後,自後臺後,將被sent和調用;或者在app被臨時中斷後,比如一個電話呼叫。並非真正的進入後臺,將會收到UIApplicationWillResignActiveNotification監聽事件。


你可以在apple文檔中查看上述流程的圖列:App States and Multitasking 。


下一部分將會介紹如何使用這些監聽,解決beginBackgroundTaskWithExpirationHander 的bug將留爲一個課後聯繫。

演示代碼:BackgroundWhatever 。


雜誌下載

在IOS5中,apple介紹了雜誌API:允許建立雜誌類報紙類的app,並且具有一些別於其它app的特性。用雜誌API創建的app在安裝到原生雜誌app裏以前它不具有app icon的。


雜誌後臺模式非常便於雜誌類app。他提供了一個API集來使app下載大文件(一般爲期刊雜誌)變得容易。即使你的app不是在active狀態,它也支持下載知道完成。


附:如果你想更具體瞭解雜誌類app,IOS5 by Tutorials有相關的章節。


實例項目的xib中,在UIWebView控件上有一個載有URL的UITextField,默認的URL爲:https://developer.apple.com/library/ios/documentation/Cocoa/Conceptual/ProgrammingWithObjectiveC/ProgrammingWithObjectiveC.pdf


這個一個PDF的鏈接文件,方便展示app進入後臺後,仍然可以繼續下載的效果。其他的大文件下載鏈接亦可。


開始在TBFourthViewController.m文件中添加2個屬性:

@property (nonatomic, strong) NKIssue *currentIssue;
@property (nonatomic, strong) NSString *issueFilename;

完善UITextFieldDelegate方法,響應編輯好textfiled,並點擊return後的操作:

#pragma mark - UITextFieldDelegate
 
- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
    self.webView.hidden = YES;
    self.progress.progress = 0.0f;
    self.progress.hidden = NO;
 
    NKLibrary *library = [NKLibrary sharedLibrary];
    for (NKIssue *issue in [library.issues copy])
    {
        [library removeIssue:issue];
    }
    self.currentIssue = [library addIssueWithName:@"test" date:[NSDate date]];
 
    NSURL *downloadURL = [[NSURL alloc] initWithString:self.txtURL.text];
    NSURLRequest *request = [NSURLRequest requestWithURL:downloadURL];
    NKAssetDownload *assetDownload = [self.currentIssue addAssetWithRequest:request];
    [assetDownload downloadWithDelegate:self];
    [textField resignFirstResponder];
    return YES;
}

首先講webView隱藏,並顯示置零的progress。

NKLibrary提供一個單例來管理app中雜誌/報紙的issues。利用它來創建NKIssue對象。


移除所有現存的issue以防添加issue時出現重名錯誤。

創建issue,命名。在真實的生產環境下,名稱需是唯一,issue名稱或者號碼序列。

以textfield的值建立NSURL對象,並創建NSURLRequest,將其添加至NKIssue對象中,返回NKAssetDownload對象,開啓下載任務,並設置self爲delegate。



實現NSURLConnection的一個委託方法(可選),即用於接收下載的進度:

#pragma mark - NSURLConnectionDownloadDelegate
 
- (void)connection:(NSURLConnection *)connection
      didWriteData:(long long)bytesWritten
 totalBytesWritten:(long long)totalBytesWritten
expectedTotalBytes:(long long)expectedTotalBytes
{
    float progress = (float)totalBytesWritten / (float)expectedTotalBytes;
    if (UIApplication.sharedApplication.applicationState == UIApplicationStateActive)
    {
        self.progress.progress = progress;
    }
    else
    {
        NSLog(@"App is backgrounded. Progress = %.1f", progress);
    }
}
 
- (void)connectionDidFinishDownloading:(NSURLConnection *)connection
                        destinationURL:(NSURL *)destinationURL
{
    self.issueFilename = destinationURL.pathComponents.lastObject;
    NSURL *fileURL = [self.currentIssue.contentURL URLByAppendingPathComponent:self.issueFilename];
 
    [[NSFileManager defaultManager] moveItemAtURL:destinationURL
                                            toURL:fileURL
                                            error:nil];
 
    if (UIApplication.sharedApplication.applicationState == UIApplicationStateActive)
    {
        self.webView.hidden = NO;
        self.progress.hidden = YES;
        NSURL *fileURL = [self.currentIssue.contentURL URLByAppendingPathComponent:self.issueFilename];
        [self.webView loadRequest:[NSURLRequest requestWithURL:fileURL]];
    }
    else
    {
        NSLog(@"App is backgrounded. Download finished");
    }
}

第一個方法在下載數據有更新時被調用。不需要太多操作,OS進行下載數據,app只要趕住UI更新即可,當然區分app處的狀態。


第二個方法是當下載完成時被調用。然後將下載的文件遷移到NewsstandAPI期望的目錄下。並且當設備空間不足時允許API進行刪除老的issues。

文件遷移之後,當app爲active時更新UI,否則打印至控制檯上。


但當app處後臺時,下載完成,將如何處理?webVIew不會更新PDF。我們可以利用監聽事件來時刻關注上述問題。


首先在上述方法中找到並提取出更新代碼,避免重複代碼(時刻銘記保持代碼DRY),在本m文件的任何地方添加如下方法:


- (void)updateWebView
{
    self.webView.hidden = NO;
    self.progress.hidden = YES;
    NSURL *fileURL = [self.currentIssue.contentURL URLByAppendingPathComponent:self.issueFilename];
    [self.webView loadRequest:[NSURLRequest requestWithURL:fileURL]];
}

從connectionDidFinishDownloading:destinationURL:移除多餘代碼,使之爲下:

  if (UIApplication.sharedApplication.applicationState == UIApplicationStateActive)
    {
        [self updateWebView];
    }
    else
    {
        NSLog(@"App is backgrounded. Download finished");
    }

添加如下方法,當app爲active狀態時調用

- (void)appBecameActive
{
    if (self.currentIssue && self.currentIssue.downloadingAssets.count == 0 && self.webView.hidden)
    {
        [self updateWebView];
    }
}

最後,在viewDidLoad最後添加:

  [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(appBecameActive)
                                                 name:UIApplicationDidBecomeActiveNotification
                                               object:nil];

無論何時添加監聽,不要忘記對應的添加移除操作,在dealloc中:

- (void)dealloc
{
   [[NSNotificationCenter defaultCenter] removeObserver:self];
}

最後一步,在info.plst中添加鍵值。這次需要2個鍵值對來使app成爲一個雜誌類app。


在Required background modes數組下添加:


接着創建一個新的key,並選擇application presents content in Newsstand,在value中改爲boolean屬性,並設置爲YES:


運行之,效果:



ok,它在前臺工作很好,但是它還未經真實的測試。


重新運行,在下載時點擊HOME,將看到打印到控制檯的信息,重回APP,將看到webview已經更新PDF,ok,正確。


假如網絡很好的情況下,可能還沒有來得及看到控制檯的信息,下載過程就很快完成了。這種情況,你可以在設備上運行該app,並使用較大文件下載,或者將網絡調至成較差的情況。


需要注意的是你的app並不含有一個正規的icon圖片,它是在newsstandAPP中的並且有個默認的雜誌icon。



演示項目:BackgroundNewsstand



5,提供VoIP服務


最後一個是一個強大的後臺模式,它允許你的APP在後臺時運行任意代碼。這個後臺模式相較‘任意時長(whatever)’的更好,因爲它沒有時長限制即不限時。

更重要的,app若崩潰或者重啓設備,APP仍然自動在後臺運行。good。


當然,使用它的前提是:你的APP必須提供給用戶VoIP功能纔可以,否則,apple將會拒掉。


VoIP app的創建超出本文所述,但我會闡述基本。

VoIP:Voice over IP或者網絡電話。本文中,你將建立一個簡單app並鏈接至服務器,在後臺時仍保持鏈接,app接收訊息時回調。


打開TBFifthViewController.m文件,添加如下屬性聲明:

@property (nonatomic, strong) NSInputStream *inputStream;
@property (nonatomic, strong) NSOutputStream *outputStream;
@property (nonatomic, strong) NSMutableString *communicationLog;
@property (nonatomic) BOOL sentPing;

在@implementation TBFifthViewController之前,定義字串常量,在鏈接中會用到:


const uint8_t pingString[] = "ping\n";
const uint8_t pongString[] = "pong\n";

下面是一個簡便方法添加到TextView上的。用它來判斷是否鏈接完成,是否中斷或者接收到訊息。

- (void)addEvent:(NSString *)event
{
    [self.communicationLog appendFormat:@"%@\n", event];
    if (UIApplication.sharedApplication.applicationState == UIApplicationStateActive)
    {
        self.txtReceivedData.text = self.communicationLog;
    }
    else
    {
        NSLog(@"App is backgrounded. New event: %@", event);
    }
}

app是active時其追加字串並更新UI,否則處在後臺模式時僅打印在控制檯上。


下面方法在用戶點擊連接按鈕時調用:

- (IBAction)didTapConnect:(id)sender
{
    if (!self.inputStream)
    {
        // 1
        CFReadStreamRef readStream;
        CFWriteStreamRef writeStream;
        CFStreamCreatePairWithSocketToHost(NULL, (__bridge CFStringRef)(self.txtIP.text), [self.txtPort.text intValue], &readStream, &writeStream);
 
        // 2
        self.sentPing = NO;
        self.communicationLog = [[NSMutableString alloc] init];
        self.inputStream = (__bridge_transfer NSInputStream *)readStream;
        self.outputStream = (__bridge_transfer NSOutputStream *)writeStream;
        [self.inputStream setProperty:NSStreamNetworkServiceTypeVoIP forKey:NSStreamNetworkServiceType];
 
        // 3
        [self.inputStream setDelegate:self];
        [self.outputStream setDelegate:self];
        [self.inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
        [self.outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
 
        // 4
        [self.inputStream open];
        [self.outputStream open];
 
        // 5
        [[UIApplication sharedApplication] setKeepAliveTimeout:600 handler:^{
            if (self.outputStream)
            {
                [self.outputStream write:pingString maxLength:strlen((char*)pingString)];
                [self addEvent:@"Ping sent"];
            }
        }];
    }
}

看起來有點複雜,因爲創建了2條stream。


1,應用CFStreamCreatePairWithSocketToHost方法是最簡便的方式,意味着此後更多的橋接(bridging)可以應用NSInputStream和NSOutputStream類。


2,以CFStreamCreatePairWithSocketToHost創建2個stream對後,將它們轉換成oc類,setProperty:forKey方法調用非常重要,由此告知OS,app在後臺時,鏈接仍需保持。該設置只需在input stream完成即可。


3,設置s2個tream的委託對象爲self,並將runloop設置爲main runloop。OS需要確定委託方法需要在哪個runloop下被調用,本例中最適合的runloop便是和app主線程相關的。爲了在接收到信息後更新UI,故需要設置在main runloop中。


4,設置好後,開啓2個stream。


5,調用setKeepAliveTimeout:handler:,app在後臺時將會定期調用handler。運行在其中做任何事情,但是它應當用於發送‘ping’到服務器,來保持鏈接可用。上述代碼設置爲每隔10 min進行ping操作(文檔所示的最小值)。在此唯一做的便是ping服務器,並打印輸出。



添加stream委託事件來接收鏈接更新:

#pragma mark - NSStreamDelegate
 
- (void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCode
{
    switch (eventCode) {
        case NSStreamEventNone:
            // do nothing.
            break;
 
        case NSStreamEventEndEncountered:
            [self addEvent:@"Connection Closed"];
            break;
 
        case NSStreamEventErrorOccurred:
            [self addEvent:[NSString stringWithFormat:@"Had error: %@", aStream.streamError]];
            break;
 
        case NSStreamEventHasBytesAvailable:
            if (aStream == self.inputStream)
            {
                uint8_t buffer[1024];
                NSInteger bytesRead = [self.inputStream read:buffer maxLength:1024];
                NSString *stringRead = [[NSString alloc] initWithBytes:buffer length:bytesRead encoding:NSUTF8StringEncoding];
                stringRead = [stringRead stringByTrimmingCharactersInSet:[NSCharacterSet newlineCharacterSet]];
 
                [self addEvent:[NSString stringWithFormat:@"Received: %@", stringRead]];
 
                if ([stringRead isEqualToString:@"notify"])
                {
                    UILocalNotification *notification = [[UILocalNotification alloc] init];
                    notification.alertBody = @"New VOIP call";
                    notification.alertAction = @"Answer";
                    [[UIApplication sharedApplication] presentLocalNotificationNow:notification];
                }
                else if ([stringRead isEqualToString:@"ping"])
                {
                    [self.outputStream write:pongString maxLength:strlen((char*)pongString)];
                }
            }
            break;
 
        case NSStreamEventHasSpaceAvailable:
            if (aStream == self.outputStream && !self.sentPing)
            {
                self.sentPing = YES;
                if (aStream == self.outputStream)
                {
                    [self.outputStream write:pingString maxLength:strlen((char*)pingString)];
                    [self addEvent:@"Ping sent"];
                }
            }
            break;
 
        case NSStreamEventOpenCompleted:
            if (aStream == self.inputStream)
            {
                [self addEvent:@"Connection Opened"];
            }
            break;
 
        default:
            break;
    }
}

該方法涵蓋了鏈接中所有可能的事件,大多數是簡單的自我說明的。


NSStreamEventHasBytesAvailable:一般發生在inputstream中,但是你必須要檢測。讀取數據至字串中,消去換行符等,打印出來。


附:如果事件是‘notify’,創建一個本地通知。在真實的VoIP中,該行爲應是對應一個接入呼叫。即使在後臺,本地通知亦展示出來。


如果是‘ping’,應該回應服務器‘pong’。


NSStreamEventHasSpaceAvailable:輸出的數據爲空,僅當首次接收到時,發送ping。



然後打開info.plist文件,在Required background Modes數組下添加App provides Voice over IP services:


運行項目前,需要一個測試服務器。你可以應用mac已有的工具,叫做netcat。可以實現簡單的基於文本的服務器。

打開終端,鍵入:

nc -l 10000

命令開始了一個在端口10000上運行的服務器,並開啓監聽鏈接,返回Xcode,運行項目:


若在模擬器上運行,可以設置地位爲127.0.0.1,若真機測試,應找出測試mac的ip地址。


點擊connect,鏈接正常開始的話,可以看到控制檯的輸出:


如果要測試通知命令,需要在真機上測試。有關通知的內容目前在模擬器上無法測試。


點擊HOME進入後臺,在終端發送ping,你仍可在控制檯輸出中看到pong的迴應。、

如果運行在真機上,當app是後臺模式時,試着發送nitify命令給它,可以看到:



演示項目:backgroundVoIP 。




何去何從?


你可下載關於本文的所有的資源文件:full Project


若想閱讀關於本文技術相關的apple文檔,請移步:Background Execution and multitasking 。文檔很好的的解釋了每個後臺模式,每個部分均有合適的外鏈文檔。


本文特別值得關注的是:being a responsible background app。在發佈你的具有後臺模式的app之前,有一些可行/不可行的細節。


全部項目在my GitHub,下載,fork,並修複相關bug,祝愉快。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章