システム開発部のTです。
普段はAndroid、iOSなどのネイティブアプリ開発やフロントエンドアプリ開発に携わっています。
皆さん、ネットワークサービスというのをご存知でしょうか。今回はそのネットワーク上に存在するサービスを一網打尽に検索するアプリの作成をしてみようと思います。
ネットワークサービスとは
本件のネットワークサービスとは、ローカルネットワーク(以降はLAN)上で提供している各種デバイスのサービスのことです。たとえば、プリンタサーバもその一つになります。最近のプリンタサーバは、LAN上にサービスとして登録し、クライアントは登録されたサービスを検索していくことで、プリンタサーバの存在を確認することができます。LAN上にサービスとして登録しておくことで、わざわざプリンタサーバ等デバイスのIPアドレスを事前に把握することなく、各デバイスへアクセスすることが可能となります。
今回は、そのLAN上に点在するサービスを検索し、それを画面上に表示できるアプリを作成していこうと思います。
なお、今回はFlutterでアプリを作成しますが、制限の関係でAndroidオンリーとさせてもらいます。
開発環境
本件では、以下の開発環境を利用しました。
Android Studio | Chipmunk | 2021.2.1 Patch 2 |
Flutter | 3.0.3 |
Dart | 2.17.5 |
アプリ機能概要
以下の機能を実装します。
- LAN内サービス検索機能
- サービスに紐付いたデバイス表示



LAN内のサービスを検索し、それを一覧表示します。
サービス名をタップすることで、次画面に遷移。サービスに紐付いたデバイス名とHost名を表示します。表示された内容をタップすることで、外部ブラウザに遷移し、デバイスのIPアドレスにアクセスを試みます。
最後のブラウザで表示できるかは、IPアドレス先のデバイスにWebサーバーが起動しているか否かで。表示されるとは限りません。
今回は、上記の機能の実装をしていきます。
利用するプラグイン
本件では、ネットワークサービスディスカバリ(以降NSD)という機能を利用するため、以下のプラグインを利用します。
準備
プロジェクトの生成
このへんは特に気にするところはなく、普通に作っていきます。
最後に「Finish」ボタンを押下してください。

プロジェクト生成後の画面です。

プラグインのインストール
pubspec.yamlを開き、以下の内容を追記後、「Pub get」を実行してください。
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
nsd: ^2.2.3 # <- 追加
url_launcher: ^6.1.5 # <- 追加
fluttertoast: ^8.0.9 # <- 任意(エラー時に表示するため)
追加後は以下の画面のイメージになっているかと思います。
追加後、画面上の`Pub get`を実行してください。

Android側の設定
AndroidManifestファイルを開き、以下のPermissionを追加します。
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
以下のイメージで追加したことを確認してください。

以上で設定は完了です。
引き続き実装に入ります。
実装・・・の前に
プロジェクト作成直後、全ての処理がmain.dartに固まっているため、機能ごとにファイルを分けます。
import 'package:flutter/material.dart';
import 'package:sample_nsd_app/my_app.dart';
void main() {
runApp(const MyApp());
}
import 'package:flutter/material.dart';
import 'package:sample_nsd_app/my_home_page.dart';
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
);
}
}
import 'package:flutter/material.dart';
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('LAN内サービスタイプ検索一覧'),
),
body: Container()
);
}
}
ここまで、ファイル分割、余計なコメント削除、最小限の処理にまとめました。
ここから実装に入ります。
LAN内サービスタイプ検索一覧画面の実装
LAN内に点在しているネットワークサービスを検索した結果を一覧として表示する画面を実装していきます。一覧には、サービス名、サービスタイプを表示します。
データ取得には、以下を参考にしました。
https://pub.dev/packages/nsd#discover-all-services-of-all-types-on-local-network
では、上記を踏まえてサービスの内容を取得していきましょう。
以下、ざっくりと書いていきます。
() async {
final discovery = await startDiscovery('_services._dns-sd._udp', autoResolve: false);
discovery.addListener(() {
for (var service in discovery!.services) {
// ここで以下の内容のサービス内容を取得する
// {service.type: _tcp.local, service.name: _bar, ...}
}
});
}()
startDiscovery()
で検索スタートします。
本来startDiscovery()
の引数にはサービスタイプ(例えば_http._tcp
等)を設定することで、設定したサービスタイプに紐づくデバイスを検索しますが、引数に_services._dns-sd._udp
と設定することで、LAN上のサービス内容を検索するようになります。
startDiscoveryからはDiscoveryのインスタンスを取得できるので、Discoveryに対してaddListenerにてコールバックを登録しておくと、検索結果取得時にコールバックでサービス内容を参照可能になります。
本件にてServiceから取得できる内容はservice.type
、およびservice.name
からのみとなります。
上記コード上のコメントにも示しているとおり、以下の内容が入ってきます。
{service.type: _tcp.local, service.name: _bar, ...}
service.name
に入っている内容は、ずばりサービス名そのものです。service.type
に入っている内容については、実際のサービスタイプではなく、通信プロトコルです。
実際のサービスタイプについては、上記で取得したサービス名+サービスタイプから.local
文字列を削除した内容となります。つまり・・・、
_bar._tcp
上記が実際のサービスタイプとなります。
上記で取得したサービスタイプの文字列を、さらにstartDiscoveryの引数に設定して検索かけることで、それに紐付いたデバイス情報を取得できるのですが、その話は後ほどとさせてください。
ここまでで、サービス情報の検索手段はご理解されたかと思うので、以下にて実際の実装を見ていただければと思います。
my_home_page.dartへの実装
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:nsd/nsd.dart';
import 'package:sample_nsd_app/service_list_page.dart';
class _ServiceTypeDataItem {
var type = "";
var name = "";
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
List<_ServiceTypeDataItem> dataList = [];
Discovery? discovery;
@override
void initState() {
super.initState();
startDiscovery('_services._dns-sd._udp', autoResolve: false).then((discovery) {
this.discovery = discovery;
discovery.addListener(listener);
}, onError: (error) {
Fluttertoast.showToast(msg: error.toString(), toastLength: Toast.LENGTH_LONG);
});
}
@override
void dispose() {
if (discovery != null) {
discovery!.removeListener(listener);
stopDiscovery(discovery!);
}
super.dispose();
}
void listener() {
if (discovery != null) {
for (var service in discovery!.services) {
var dataSource = _ServiceTypeDataItem();
dataSource.type = '${service.name}.${service.type?.replaceAll('.local', '')}';
dataSource.name = service.name ?? '';
final isNotEmpty = dataList.where((data) {
return (data.type == dataSource.type && data.name == dataSource.name);
}).isNotEmpty;
if (!isNotEmpty) {
dataList.add(dataSource);
}
}
setState((){});
}
}
@override
Widget build(BuildContext context) {
dataList.sort((a,b) => a.name.compareTo(b.name));
return Scaffold(
appBar: AppBar(
title: const Text('LAN内サービス検索一覧'),
),
body: Stack(
children: [
ListView.builder(
itemCount: dataList.length,
itemBuilder: (context, index) {
var e = dataList[index];
return ListTile(
title: Text('サービス名:${e.name}'),
subtitle: Text('サービスタイプ:${e.type}'),
);
},
),
],
),
);
}
}
以降、主要な処理をピックアップします。
initState()
@override
void initState() {
super.initState();
startDiscovery('_services._dns-sd._udp', autoResolve: false).then((discovery) {
this.discovery = discovery;
discovery.addListener(listener);
}, onError: (error) {
Fluttertoast.showToast(msg: error.toString(), toastLength: Toast.LENGTH_LONG);
});
}
initState
にて、startDiscovery
しています。
discovery取得失敗時はToast
でエラー表示していますが、こちらは任意で。
登録するlistenerは43行目に定義しています。
dispose()
@override
void dispose() {
if (discovery != null) {
discovery!.removeListener(listener);
stopDiscovery(discovery!);
}
super.dispose();
}
dispose
で、widget終了後の後始末を記載しています。
nsdプラグインの公式ページにもシレェ〜っと記載されているのですが、stopDiscovery
は必須です。
これをしないと、無駄なdiscoveryが増え続けていき、最終的に以下の例外が発生します。
Unhandled Exception: NsdError (message: "Maximum outstanding requests reached", cause: maxLimit)
listener()
void listener() {
if (discovery != null) {
for (var service in discovery!.services) {
var dataSource = _ServiceTypeDataItem();
dataSource.type = '${service.name}.${service.type?.replaceAll('.local', '')}';
dataSource.name = service.name ?? '';
final isNotEmpty = dataList.where((data) {
return (data.type == dataSource.type && data.name == dataSource.name);
}).isNotEmpty;
if (!isNotEmpty) {
dataList.add(dataSource);
}
}
setState((){});
}
}
discovery
に登録するコールバックメソッドです。discovery.services
から各種サービス情報を取得できるので、各データを_ServiceTypeDataItem
上に保持し、dataListに追加します。
最後にsetStateさせることで画面再描画が走り、画面上に一覧表示していきます。
listener
は何度もコールされるため、dataListに重複で追加されないように重複チェック処理を入れています。
build()
@override
Widget build(BuildContext context) {
dataList.sort((a,b) => a.name.compareTo(b.name));
return Scaffold(
appBar: AppBar(
title: const Text('LAN内サービス検索一覧'),
),
body: Stack(
children: [
ListView.builder(
itemCount: dataList.length,
itemBuilder: (context, index) {
var e = dataList[index];
return ListTile(
title: Text('サービス名:${e.name}'),
subtitle: Text('サービスタイプ:${e.type}'),
);
},
),
],
),
);
}
dataListをサービス名順にソートし、画面のUIに反映しています。
ここまで実装し、以下が実際の画面になります。

いかがでしょうか。
私の部屋にはいろいろな機器があるので、Iot的なものも複数表示されていますね。
ただ、上記だけだとサービス名のみで、どういうデバイスなのか不明なので、次の実装ではサービス名に紐付いたデバイスを表示する機能を実装します。
デバイス検索結果一覧画面の実装
先程の画面にて、サービスタイプが取得できたかと思いますが、今度はそのサービスタイプの文字列を引数としてstartDiscoveryします。
以下、実装の参考にした公式ページです。
https://pub.dev/packages/nsd#basic-usage
新たにdevice_list_page.dart
ファイルを追加します。
以下、実際の実装内容です。
device_list_page.dartの実装
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:nsd/nsd.dart';
import 'package:url_launcher/url_launcher.dart';
class DeviceListPage extends StatefulWidget {
final String serviceType;
const DeviceListPage({Key? key, required this.serviceType}) : super(key: key);
@override
State<DeviceListPage> createState() => _DeviceListPageState();
}
class _DeviceDataItem {
var type = "";
var name = "";
var host = "";
var port = 0;
}
class _DeviceListPageState extends State<DeviceListPage> with RouteAware {
List<_DeviceDataItem> dataList = [];
Discovery? discovery;
@override
void initState() {
super.initState();
startDiscovery(widget.serviceType).then((discovery) {
this.discovery = discovery;
discovery.addServiceListener(listener);
}, onError: (error) {
Fluttertoast.showToast(msg: error.toString(), toastLength: Toast.LENGTH_LONG);
});
}
@override
void dispose() {
if (discovery != null) {
stopDiscovery(discovery!);
discovery?.removeServiceListener(listener);
discovery?.dispose();
}
super.dispose();
}
void listener(service, status) {
if (status == ServiceStatus.found) {
var dataSource = _DeviceDataItem();
dataSource.type = service.type ?? '';
dataSource.name = service.name ?? '';
dataSource.host = service.host ?? '';
dataSource.port = service.port ?? 0;
final isNotEmpty = dataList.where((data) {
return (data.type == dataSource.type && data.name == dataSource.name && data.host == dataSource.host && data.port == dataSource.port);
}).isNotEmpty;
if (!isNotEmpty) {
dataList.add(dataSource);
}
setState((){});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('[${widget.serviceType}]一覧'),
),
body: Stack(
children: [
ListView.builder(
itemCount: dataList.length,
itemBuilder: (context, index) {
var e = dataList[index];
return ListTile(
title: Text(e.name),
subtitle: Text('Host:${e.host}${e.port == 0 ? '' : ':${e.port}'}'),
onTap: () {
if (e.port == 0) {
_launchUrl('http://${e.host}');
} else {
_launchUrl('http://${e.host}:${e.port}');
}
},
);
},
),
],
),
);
}
Future<void> _launchUrl(String urlText) async {
Uri url = Uri.parse(urlText);
await launchUrl(url, mode :LaunchMode.externalApplication);
}
}
必要な箇所をピックアップします。
DeviceListPageのコンストラクタ
class DeviceListPage extends StatefulWidget {
final String serviceType;
const DeviceListPage({Key? key, required this.serviceType}) : super(key: key);
@override
State<DeviceListPage> createState() => _DeviceListPageState();
}
サービスタイプの文字列を必須の引数として追加しています。
initState()
@override
void initState() {
super.initState();
startDiscovery(widget.serviceType).then((discovery) {
this.discovery = discovery;
discovery.addServiceListener(listener);
}, onError: (error) {
Fluttertoast.showToast(msg: error.toString(), toastLength: Toast.LENGTH_LONG);
});
}
DeviceListPageコール時に受領するサービスタイプを引数として、startDiscoveryを実行。
前画面同様にlistenerを登録しますが、今回はaddServiceListener
に対して登録します。
dispose()
@override
void dispose() {
if (discovery != null) {
stopDiscovery(discovery!);
discovery?.removeServiceListener(listener);
discovery?.dispose();
}
super.dispose();
}
前画面同様、画面終了時の後始末も実装。stopDiscovery
は忘れないように!
listener()
void listener(service, status) {
if (status == ServiceStatus.found) {
var dataSource = _DeviceDataItem();
dataSource.type = service.type ?? '';
dataSource.name = service.name ?? '';
dataSource.host = service.host ?? '';
dataSource.port = service.port ?? 0;
final isNotEmpty = dataList.where((data) {
return (data.type == dataSource.type && data.name == dataSource.name && data.host == dataSource.host && data.port == dataSource.port);
}).isNotEmpty;
if (!isNotEmpty) {
dataList.add(dataSource);
}
setState((){});
}
}
こちらも処理内容は前画面のlistener
と変わりないのですが、引数にservice、statusがあるのでstatusの判定にて存在していれば画面上に表示するデータを設定するようにしています。
seivice.type
はサービスタイプ、service.name
はデバイス名、service.host
はホスト名(IPアドレス)、service.port
は実際にアクセスするためのポート番号が入ってきます。
もちろん、listenerは何度もコールされるため、データの重複を防ぐための重複チェック処理も入れました。
最後にsetState
することで画面に反映されます。
build()
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('[${widget.serviceType}]一覧'),
),
body: Stack(
children: [
ListView.builder(
itemCount: dataList.length,
itemBuilder: (context, index) {
var e = dataList[index];
return ListTile(
title: Text(e.name),
subtitle: Text('Host:${e.host}${e.port == 0 ? '' : ':${e.port}'}'),
onTap: () {
if (e.port == 0) {
_launchUrl('http://${e.host}');
} else {
_launchUrl('http://${e.host}:${e.port}');
}
},
);
},
),
],
),
);
}
Future<void> _launchUrl(String urlText) async {
Uri url = Uri.parse(urlText);
await launchUrl(url, mode :LaunchMode.externalApplication);
}
最後はUI描画処理です。listener
で取得したデバイス情報を一覧表示します。
一覧には、デバイス名と、デバイスのアクセス先でもあるホスト名、ポート番号を表示しました。
また、任意の内容をタップすることで、外部ブラウザでアクセスを試みるようにしております。
(http://〜でアクセスするようにしていますが、必ずしもブラウザ上に表示するとは限りません。)
my_home_page.dartに画面遷移処理追加
@override
Widget build(BuildContext context) {
dataList.sort((a,b) => a.name.compareTo(b.name));
return Scaffold(
appBar: AppBar(
title: const Text('LAN内サービス検索一覧'),
),
body: Stack(
children: [
ListView.builder(
itemCount: dataList.length,
itemBuilder: (context, index) {
var e = dataList[index];
return ListTile(
title: Text('サービス名:${e.name}'),
subtitle: Text('サービスタイプ:${e.type}'),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context)=>DeviceListPage(serviceType: e.type))); // 画面遷移処理追加
},
);
},
),
],
),
);
}
上記のようにListTile
にonTap
を追加し、押下イベント処理を追加します。
押下時に、Navigator.push
を実行し画面遷移するようにしてください。
実際の画面イメージは以下となります。

上記の場合、_http._tcp
というサービスタイプで検索した結果が表示されています。
一覧をタップした場合、アクセス先がWebサーバーの場合、ブラウザ上には以下のように画面が表示されるはずです。

以上ですべての実装が終了です。
最後に
いかがだったでしょうか。
私自身、ネットワークサービス関連の開発に関わったことはなく、今回担当案件で勉強する必要があったので、どういったものなのかを理解する過程で本件の記事を書いてみました。
私自身も実装してみて、実際に動かしてみたところ、自分の知らないところでいろいろなサービスが動いているのだと改めて知ることができました。
自分の身の回りにどういうサービスが動いているのか、知りたい方はぜひ今回の実装を試していただければと思います。
本件では、ネットワーク上のサービスを検出する機能の説明でしたが、今回のnsdプラグインにはネットワーク上にサービスを登録する機能もあるので、次回はサービス登録機能についても書いていければと思います。