almost 4 years ago

大概七八個月前,因工作上需要,研究一些讓鄰近的行動裝置(不論是iDevice或是Android裝置)能夠知道彼此的方法,至於用途是什麼就不能說了。若不考量行動裝置作業系統版本的話,Bluetooth Low Engery是一個不錯的方案,但從2014年6月Android 4.3的市佔率來看,很難讓軟體開發者直接捨棄4.3以前的使用者。傳統的Bluetooth方案,受限於iDevice只允許通過MFi Program認證的Bluetooth裝置才能連線,因此iDevice和Android無法透過Bluetooth建立連線。最後,雖然iOS支援Bonjour over Bluetooth,但Android目前不支援,反之,Android支援Bonjour over WiFi Direct,但iOS目前不支援,可是若使用者在有WiFi AP且能取得IP的環境(Bonjour是在TCP/IP層上的通訊協定)這前提下,Bonjour也許是一個可行的方案,也是本文的主題。至於下表中的iBeacon待下回分曉。

Technology iOS Android
Bluetooth iOS 5 & MFi required Android 2.0
Bluetooth LE iOS 5 Android 4.3
Bonjour Over WiFi & Bluetooth (iOS 5) Over WiFi (4.1 or 1.6 with JmDNS) &
WiFi Direct (4.3)
iBeacon iOS 7 Android 4.3 and third-party library required

首先,先看Android吧!畢竟Bonjour幾乎是iOS/OS X的原生居民,不太需要擔心。本文使用Android SDK 4.1原生的Network Service Discovery API,如果想讓更早之前的Android能使用Bonjour,可以使用third-party的JmDNS,網路上也有相當完整的使用範例。要使用Bonjour,應用程式需要取得INTERNETCHANGE_WIFI_MULTICAST_STATE權限,因此要在AndroidManifest.xml中加入Code List 1所列的二行敘述。

Code List 1 - Android use permission
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>

接著替Activity的layout XML中加入一個ListViewButton用來顯示找到的Bonjour服務和發佈及啟動服務搜尋。

Code List 2 - Activity layout
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin" >
    <ListView
        android:id="@+id/servicesView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_alignParentBottom="true"
        android:layout_alignParentLeft="true"
        android:layout_alignParentRight="true"
        android:layout_alignParentTop="true" >
    </ListView>
    <RelativeLayout
        android:id="@+id/buttons"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" >
    <Button
        android:id="@+id/discoverButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_alignParentRight="true"
        android:text="@+string/discover"
        android:onClick="discoverButtonPressed" />
    <Button
        android:id="@+id/publishButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_alignParentLeft="true"
        android:text="@+string/publish"
        android:onClick="publishButtonPressed" />
    </RelativeLayout>
</RelativeLayout>

不論發佈服務或是搜尋服務,都需要NsdManager,所以如Code List 3所示,在建立Activity被時,除取得ListViewButton物件外,也一併取得NsdManager物件。為了簡化,兩個按鈕都是雙態開關,發佈或取消發佈服務,開始或停止搜尋服務,所以有兩個boolean變數_published_discovering記住按鈕的狀態。

Code List 3 - Obtain the ServiceDiscoverManager on creating activity
@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    _published = false;
    _discovering = false;

    _servicesView = (ListView)findViewById(R.id.servicesView);
    _publishButton = (Button)findViewById(R.id.publishButton);
    _discoverButton = (Button)findViewById(R.id.discoverButton);
    _serviceDiscoverManager = (NsdManager)getSystemService(Context.NSD_SERVICE);

    _discoveredServices = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1);
    _servicesView.setAdapter(_discoveredServices);
}

取得NsdManager後可以發佈(註冊)服務,讓其他裝置能搜尋到,要注意的是,Bonjour只是讓一個讓網路上的裝置知道彼此有什麼服務,實際的服務並不是Bonjour提供,因此發佈服務時,會需要實際提供服務的TCP/UDP Port編號,所以程式在發佈服務前,先建立一個ServerSocket以取得port編號。除了編號,一個NsdServiceInfo需要一個可以辨識服務的唯一名稱,以及服務的類型,需要注意的是,服務類型是由應用類型_chat.搭配協定類型_tcp.組成,基本上是受IANA控管的,服務類型可以在這裡查,當然也可以註冊一個新的。準備好NsdServiceInfo後,就可以透過NsdManagerregisterService(NsdServiceInfo, int, RegistrationListener)函式註冊。

Code List 4 - Prepare and publish a service
private void publishService() {
    int port = startServerSocket();
    if (port == 0) {
        Toast.makeText(this, "unable to create a server socket for the service", 3);
        _publishButton.setClickable(true);
        _publishButton.setText(R.string.publish);
        return;
    }
    NsdServiceInfo service = createService(port);
    _serviceDiscoverManager.registerService(service, NsdManager.PROTOCOL_DNS_SD, this);
    _published = true;
    Log.d("network", "registering a service " + service);
}

private NsdServiceInfo createService(int port) {
    NsdServiceInfo service  = new NsdServiceInfo();
    service.setServiceName("someone on Android");
    service.setServiceType("_chat._tcp.");
    service.setPort(port);
    return service;
}

註冊服務函式是一個非同步的API,註冊成功或失敗會透過註冊時的第三個參數RegistrationListener通知。所以在onServiceRegistered(NsdServiceInfo)onRegistrationFailed(NsdServiceInfo, int)更新UI的狀態,要注意的是,這兩個函式並不會在main thread中呼叫,所以,要更新UI,必須用runOnUiThread(Runnable)將更新UI的程式在main thread中執行。

Code List 5 - Update UI based on the registration status
public void onRegistrationFailed(NsdServiceInfo serviceInfo, final int errorCode) {
    closeServerSocket();
    final Context context = this;
    runOnUiThread(new Runnable() {
        public void run() {
            _publishButton.setClickable(true);
            _publishButton.setText(R.string.publish);
            Toast.makeText(context, "failed to publish a bonjour service", 3).show();
            Log.d("network", "failed to publish a bounjour serverice, error code: " + errorCode);
        }
    });
}

public void onServiceRegistered(NsdServiceInfo serviceInfo) {
    final Context context = this;
    runOnUiThread(new Runnable() {
        public void run() {
            _publishButton.setClickable(true);
            _publishButton.setText(R.string.depublish);
            Toast.makeText(context, "a bonjour service published", 3).show();
        }
    });
}

搜尋服務就相對簡單一點,呼叫NsdManagerdiscoverServices(String, int, DiscoveryListener),第一個參數可以限定想搜尋的服務類型,和註冊一樣,這是一個非同步API,成功、失敗、找到新服務或是某個服務消失了,都會透過第三個參數的DiscoveryListener告知,所以在onServiceFound(NsdServiceInfo)將找到的服務加到清單中,onServiceLost(NsdServiceInfo)將消失的服務從清單中移除。

Code List 6 - Update the discovered services to the list view
private void stopDiscovery() {
    if (_discovering) {
        _discovering = false;
        _discoveredServices.clear();
        _serviceDiscoverManager.stopServiceDiscovery(this);
    }
}

private void startDiscovery() {
    if (!_discovering) {
        _discovering = true;
        _serviceDiscoverManager.discoverServices("_chat._tcp.", NsdManager.PROTOCOL_DNS_SD, this);
    }
}

public void onServiceFound(final NsdServiceInfo serviceInfo) {
    Log.d("network", "service found: " + serviceInfo.getServiceName());
    runOnUiThread(new Runnable() {
        public void run() {
            _discoveredServices.add(serviceInfo.getServiceName());
        }
    });
}

public void onServiceLost(final NsdServiceInfo serviceInfo) {
    Log.d("network", "service lost: " + serviceInfo.getServiceName());
    runOnUiThread(new Runnable() {
        public void run() {
            _discoveredServices.remove(serviceInfo.getServiceName());
        }
    });
}

DiscoveryListenerRegistrationListener其他函式,以及剩餘沒介紹到的實作請參考我放在GitHub上的範例程式碼,接著提供兩個函式處理按鈕被按下去的事件就算是大致完成了(有些錯誤處理沒有處理到),可以開始寫iOS版。

Code List 7 - Handle the button pressed event
public void publishButtonPressed(View view) {
    if(_published) {
        depublishService();
    }
    else {
        startPublishService();
    }
}

public void discoverButtonPressed(View view) {
    if(!_discovering) {
        _discoverButton.setText(R.string.discovering);
        startDiscovery();
    }
    else {
        stopDiscovery();
    }
}

和Android相同,用XCode拉出一個顯示結果的畫面檔並產生對應的BonjourDiscoveredServicesViewController

iOS版當初寫的時候比較用心在再封裝上(Android寫的比較趕一點),所以結構上和Android上有些不同,首先在搜尋的部分,有一個BonjourDiscoveredServices管理搜尋到的服務,然後透過BonjourDiscoveredServicesDelegate通知UI服務內容數量上的變化。

Code List 8 - The protocol to handle the services changed event
#import <Foundation/Foundation.h>

@protocol BonjourDiscoveredServicesDelegate <NSObject>

- (void)discoveredServicesChanged;

@end

BonjourDiscoveredServices封裝搜尋服務的NSNetServiceBrowser,同時實作NSNetServiceBrowserDelegateUITableViewDataSource兩個Protocol,前者負責處理找到服務及服務消失的callback,後者根據找到的服務數量,提供UITableView所需要的UITableViewCell (BonjourDiscoveredServiceCell實作請參考GitHub)。然後提供startDiscoverystopDiscovery供外部使用,其餘細節都封裝在BonjourDiscoveredServices內部。

Code List 9 - The header file of BonjourDiscoveredServices
#import "BonjourDiscoveredServicesDelegate.h"

@interface BonjourDiscoveredServices : NSObject<NSNetServiceBrowserDelegate, UITableViewDataSource>

- (void)startDiscovery;

- (void)stopDiscovery;

@property (weak, nonatomic) id<BonjourDiscoveredServicesDelegate> delegate;

@end

NSNetServiceBrowser很容易使用,建立物件,設定delegate然後呼叫searchForServicesOfType:inDomain,若設定includesPeerToPeerYES,會使用Bonjour over Bluetooth搜尋Bluetooth提供的服務。當找到服務時,會呼叫netServiceBrowser:(NSNetServiceBrowser*) didFindService:(NSNetService*) moreComing:(BOOL)函式。服務消失時呼叫netServiceBrowser:(NSNetServiceBrowser*) didRemoveService:(NSNetService*) moreComing:(BOOL)。比較有意思是的時第三個參數會告知事件接收者還有沒有後續服務,所以可以一次處理完所有找到的服務後,再通知UI更新,不過,我不想等,所以每當有新服務或消失,我更新容器後馬上通知UI更新。

Code List 10 - The implementation of BonjourDiscoveredServices
#import "BonjourDiscoveredServices.h"

#import "BonjourDiscoveredServiceCell.h"

@implementation BonjourDiscoveredServices {
    NSArray* _serviceNames;
    NSNetServiceBrowser* _browser;
    NSMutableDictionary* _servcies;
}

- (instancetype)init {
    if (self = [super init]) {
        _servcies = [NSMutableDictionary new];
        _browser = [NSNetServiceBrowser new];
        _browser.delegate = self;
        _browser.includesPeerToPeer = YES;
    }
    return self;
}

- (void)startDiscovery {
    [_browser searchForServicesOfType:@"_chat._tcp." inDomain:@"local."];
}

- (void)stopDiscovery {
    [_browser stop];
    dispatch_async(dispatch_get_main_queue(), ^() {
        [self removeAll];
    });
}

- (void)addService:(NSNetService*)service {
    if (service.name != nil && service.name.length > 0) {
        [_servcies setObject:service forKey:service.name];
        _serviceNames = [[_servcies allKeys] sortedArrayUsingSelector:@selector(compare:)];
    }
    [self.delegate discoveredServicesChanged];
}

- (void)removeService:(NSNetService*)service {
    if (service.name != nil && service.name.length > 0) {
        [_servcies removeObjectForKey:service.name];
        _serviceNames = [[_servcies allKeys] sortedArrayUsingSelector:@selector(compare:)];
    }
    [self.delegate discoveredServicesChanged];
}

- (void)removeAll {
    [_servcies removeAllObjects];
    _serviceNames = @[];
    [self.delegate discoveredServicesChanged];
}

#pragma mark - UITableViewDataSource
- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section {
    return [_servcies count];
}

- (UITableViewCell*)tableView:(UITableView*)tableView cellForRowAtIndexPath:(NSIndexPath*)indexPath {
    BonjourDiscoveredServiceCell* cell = (BonjourDiscoveredServiceCell*)[tableView dequeueReusableCellWithIdentifier:BonjourDiscoveredServiceCellIdentifier];
    if (cell == nil) {
        cell = [[[NSBundle mainBundle] loadNibNamed:BonjourDiscoveredServiceCellIdentifier owner:nil options:nil] firstObject];
    }
    NSNetService* service = [_servcies objectForKey:[_serviceNames objectAtIndex:indexPath.row]];
    cell.service = service;
    return cell;
}

#pragma mark - NSNetServiceBrowserDelegate
- (void)netServiceBrowser:(NSNetServiceBrowser*)browser didFindService:(NSNetService*)service moreComing:(BOOL)moreComing {
    NSLog(@"service: %@ found", service);
    dispatch_async(dispatch_get_main_queue(), ^() {
        [self addService:service];
    });
}

- (void)netServiceBrowser:(NSNetServiceBrowser*)aNetServiceBrowser didNotSearch:(NSDictionary*)errorDict {
    NSLog(@"failed to discover services, due to %@", errorDict);
}

- (void)netServiceBrowser:(NSNetServiceBrowser*)browser didRemoveService:(NSNetService*)service moreComing:(BOOL)moreComing {
    NSLog(@"service: %@ removed", service);
    dispatch_async(dispatch_get_main_queue(), ^() {
        [self removeService:service];
    });
}

- (void)netServiceBrowserDidStopSearch:(NSNetServiceBrowser*)aNetServiceBrowser {
    NSLog(@"service browser stopped");
}

@end

如果目的是想知道附近其他裝置的存在,有沒有真的提供服務到沒這麼重要,所以Code List 11中建立NSNetService物件時,並沒有建立提供服務用的server socket,發佈服務也很簡單,建立NSNetService物件,剛剛沒有提到的,不論是iOS或是Android,服務類型的_chat._tcp.前綴底線是必須的,建立服務時,domain可以填空字串,預設會是host.。設定delegate,然後呼叫publish,想結束服務,呼叫stop即可,所有結果都透過delegate通知,例如透過呼叫netServiceDidPublish:(NSNetService*)sender函式通知服務成功發佈。

Code List 11 - The implementation of BonjourDiscoveredServicesViewController
#import "BonjourDiscoveredServicesViewController.h"

#import "BonjourDiscoveredServices.h"

@implementation BonjourDiscoveredServicesViewController {
    NSNetService* _publishingService;
    BonjourDiscoveredServices* _services;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    self.title = @"Bonjour";
    _services = [[BonjourDiscoveredServices alloc] init];
    _services.delegate = self;
    self.servicesView.dataSource = _services;
    _publishingService = [[NSNetService alloc] initWithDomain:@"" type:@"_chat._tcp." name:@"someone on iOS" port:9166];
    _publishingService.delegate = self;
}

- (void)discoveredServicesChanged {
    [self.servicesView reloadData];
}

- (IBAction)pushlishService:(id)sender {
    [self.publishButton setEnabled:NO];
    if (self.published) {
        [_publishingService stop];
    }
    else {
        [self.publishButton setTitle:@"Publishing" forState:UIControlStateNormal];
        [_publishingService publish];
    }
}

- (IBAction)discoverServices:(id)sender {
    if (self.discovering) {
        [_services stopDiscovery];
        self.discovering = NO;
        [self.discoverButton setTitle:@"Discover" forState:UIControlStateNormal];
    }
    else {
        [_services startDiscovery];
        self.discovering = YES;
        [self.discoverButton setTitle:@"Discovering" forState:UIControlStateNormal];
    }
}

- (void)netServiceDidStop:(NSNetService*)sender {
    [self resetPublishStatus];
    NSLog(@"service depublished");
}

- (void)netService:(NSNetService*)sender didNotPublish:(NSDictionary*)errorDict {
    [self resetPublishStatus];
    NSLog(@"failed to publish services: %@, due to %@", sender.description, errorDict);
}

- (void)netServiceDidPublish:(NSNetService*)sender {
    self.published = YES;
    [self.publishButton setEnabled:YES];
    [self.publishButton setTitle:@"Depublish" forState:UIControlStateNormal];
    NSLog(@"service: %@ published", sender.description);
}

- (void)resetPublishStatus {
    self.published = NO;
    [self.publishButton setEnabled:YES];
    [self.publishButton setTitle:@"Publish" forState:UIControlStateNormal];
}

@end

Ok,程式寫完,該開始玩玩看了,在iOS及Android各自打開程式,讓兩個裝置在同個AP中取得IP,按下Publish和Discover按鈕,稍微等個幾秒,雙方的畫面上應該都會出現自己發佈的服務和彼此發出的服務,這時若在某一方按下Depublish按鈕,彼此的畫面上應該會突然少了一個服務,有趣吧。

需要IP的這個前提是否是真的呢?是真的,實驗剛開始時,我借用的Android裝置並沒有被我加到家裡AP的白名單中,程式寫完試了半天,沒有得到任何錯誤,但就是找不到iOS裝置上的服務,當時還懷疑Network Service Discovery API是不是和Bonjour協定不相容,差一點想換JmDNS試試,後來突然想到Android一直沒拿到IP,將裝置加到AP的白名單後順利拿到IP,同時也在畫面上看到iOS提供的服務了。

最後,完整的程式碼,包含iOS版及Android版都放在GitHub上,對程式有任何疑問都可以讓我知道。

← About the Optional Design in Swift & Java Immutable Interface →