放浪軍師のアプリ開発局

VTuberみたいなアプリケーション夏狂乱など、自由気ままにアプリを開発したりしています。他にもいろいろやってます。尚、このブログはわからないところを頑張って解決するブログであるため、正しい保証がありません。ご注意ください。

socket通信を用いたチャットアプリを作ってみた

放浪軍師のXamarin.Formsによるアプリ開発
今回はsocket通信を用いたチャットアプリを作ってみたので公開してみようと思います。
まず、初めて訪問された方は以下をお読みください。
www.gunshi.info

socket通信でチャットアプリ作成

以前展示会でsocket通信を用いたIoTもどきみたいなのを展示したんですが、その一部を切り出してチャットアプリとして作り直してみましたので公開してみようと思います。
ま、PCLだけでいけるのでC#で書いた事ある人なら楽勝なんじゃないですかね?俺はC#も知らないから大変だったけどね!!!!いつもの事とか言うな。

socket通信って何さ?

socket通信ってのはLANやWifiを使う通信方式みたいです。ポイントとしては、サーバー側とクライアント側が存在し、サーバー側が受信待機しているところにクライアント側がアクセスするという所ですかね。Bluetoothの場合は1対1が基本みたいですが、socket通信の場合はサーバー1:クライアント多で通信できます。但し、今回のアプリは1:1しか出来ません。許して!
ちなみに展示会ではRaspberry Piとの通信に使いました。Raspberry Pi側は俺はノータッチだったけど、そのうち扱ってみたい。

コード

正直今の俺が持つほぼ全ての技術を注ぎ込んだレベルの代物です。大した事ねーなとか言わないで…

Views.MainPage
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="SocketsTest.Views.MainPage"
             Title="Socket通信">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="5*" />
            <RowDefinition Height="*" />
            <RowDefinition Height="*" />
            <RowDefinition Height="4*" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <TableView Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3">
            <TableView.Root>
                <TableRoot>
                    <TableSection Title="設定">
                        <SwitchCell Text="{Binding ServerSwitchInfo.Value}" On="{Binding ServerSwitch.Value}" IsEnabled="{Binding SettingIsEnabled.Value}"/>
                        <EntryCell Label="サーバー側IPアドレス" Text ="{Binding IpAddress.Value}" Placeholder="(192.168.0.77)" Keyboard="Email" IsEnabled="{Binding IPAddressSettingIsEnabled.Value}"/>
                        <EntryCell Label="ポート番号" Text ="{Binding Port.Value}" Placeholder="(9999)" Keyboard="Email" IsEnabled="{Binding SettingIsEnabled.Value}"/>
                    </TableSection>
                </TableRoot>
            </TableView.Root>
        </TableView>
        <Button Grid.Row="1" Grid.Column="0" Text = "Open" Command="{Binding OpenCommand}" />
        <Button Grid.Row="1" Grid.Column="1" Text = "Send" Command="{Binding SendCommand}" />
        <Button Grid.Row="1" Grid.Column="2" Text = "Close" Command="{Binding CloseCommand}" />
        <Entry  Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Text = "{Binding SendData.Value}" Placeholder="(送信データを入力してSendを押す)" />
        <ListView Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3" ItemsSource="{Binding ActionView}" />
    </Grid>

</ContentPage>

まぁここは特筆すべき点はないかと思います。

ViewModels.MainPageViewModel.cs
using Prism.Navigation;
using Reactive.Bindings;
using System;
using System.Linq;
using System.Reactive.Disposables;
using SocketsTest.Models;
using Reactive.Bindings.Extensions;
using System.Reactive.Linq;

namespace SocketsTest.ViewModels
{
    public class MainPageViewModel : ViewModelBase, IDisposable
    {
        //メモリリーク防止
        private CompositeDisposable Disposable { get; } = new CompositeDisposable();

        //情報
        public ReactiveProperty<bool> ServerSwitch { get; private set; }
        public ReactiveProperty<bool> SettingIsEnabled { get; private set; }
        public ReactiveProperty<string> ServerSwitchInfo { get; private set; }
        public ReactiveProperty<string> IpAddress { get; private set; }
        public ReactiveProperty<bool> IPAddressSettingIsEnabled { get; private set; }
        public ReactiveProperty<int> Port { get; private set; }
        public ReactiveProperty<string> SendData { get; private set; }

        // 挙動情報(エラーや通信履歴)
        public ReadOnlyReactiveCollection<string> ActionView { get; }

        // 開始終了
        public ReactiveCommand OpenCommand { get; private set; }
        public ReactiveCommand CloseCommand { get; private set; }
        public ReactiveCommand SendCommand { get; private set; }

        // Models.Socket
        private Socket socket = new Socket();

        public MainPageViewModel(INavigationService navigationService)
            : base(navigationService)
        {

            //ViweModel→Model 
            this.ServerSwitch = ReactiveProperty.FromObject(socket, x => x.IsServer).AddTo(this.Disposable);
            this.IpAddress = ReactiveProperty.FromObject(socket, x => x.IpAddress).AddTo(this.Disposable);
            this.Port = ReactiveProperty.FromObject(socket, x => x.Port).AddTo(this.Disposable);
            //ViewModel←Model
            this.SettingIsEnabled = socket.ObserveProperty(x => x.IsOpen).Select(x => !x).ToReactiveProperty().AddTo(this.Disposable);
            this.CloseCommand = socket.ObserveProperty(x => x.IsOpen).ToReactiveCommand().AddTo(this.Disposable);
            this.OpenCommand = socket.ObserveProperty(x => x.IsOpen).Select(x => !x).ToReactiveCommand().AddTo(this.Disposable);
            this.SendCommand = socket.ObserveProperty(x => x.IsOpen).ToReactiveCommand().AddTo(this.Disposable);
            this.ActionView = socket.ActionInfo.ToReadOnlyReactiveCollection().AddTo(this.Disposable);
            //ViewModel=Model
            this.SendData = socket.ToReactivePropertyAsSynchronized(x => x.SendData).AddTo(this.Disposable);

            //ViweModel内
            this.ServerSwitchInfo = this.ServerSwitch.Select(x => x ? "サーバー" : "クライアント").ToReactiveProperty().AddTo(this.Disposable);
            this.IPAddressSettingIsEnabled = this.SettingIsEnabled.CombineLatest(this.ServerSwitch, (x, y) => x && !y) //クライアント且つCLOSE時のみIPアドレスは入力編集が可能
                                                 .ToReactiveProperty().AddTo(this.Disposable);

            //ボタン
            OpenCommand.Subscribe(_ => socket.Open());
            CloseCommand.Subscribe(_ => socket.Close());
            SendCommand.Subscribe(_ => socket.Send());

        }

        /// <summary>
        /// メモリリーク防止
        /// </summary>
        public void Dispose()
        {
            this.Disposable.Dispose();
        }
    }
}

展示会で作った頃はViewModelが膨大になっていましたが今回は最小限に抑えられているんじゃないかな?MVVMを厳密に守った場合、ViewModelはこんな風にロジックを一切排除した形式が望ましいんじゃないかと思います。ViewModelとModelのBindingに関してはReactivePropertyをゴリゴリ使って記述してみました。この辺の挙動についてはかずきさんのブログで詳しく紹介されています。
blog.okazuki.jp
尚、ViewModelとModelのBindingについては、今度ちっさなサンプル例みたいなのを用意しようかと考えています。絶対忘れちゃうからね!

Models.Socket.cs
using System;
using System.Text;
using Prism.Mvvm;
using System.Net.Sockets;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using System.Collections.ObjectModel;
//using SocketsTest.Collections;

namespace SocketsTest.Models
{
    /// <summary>
    /// Soket通信用
    /// </summary>
    class Socket : BindableBase
    {
        private string ipAddress = "192.168.1.1";
        public string IpAddress
        {
            get => this.ipAddress;
            set => this.SetProperty(ref this.ipAddress, value);
        }
        private int port = 9999;
        public int Port
        {
            get => this.port;
            set => this.SetProperty(ref this.port, value);
        }
        private bool isServer;
        public bool IsServer
        {
            get => this.isServer;
            set => this.SetProperty(ref this.isServer, value);
        }
        private bool isOpen;
        public bool IsOpen
        {
            get => this.isOpen;
            set => this.SetProperty(ref this.isOpen, value);
        }
        private string sendData;
        public string SendData
        {
            get => this.sendData;
            set => this.SetProperty(ref this.sendData, value);
        }

        public ObservableCollection<string> ActionInfo { get; } = new ObservableCollection<string>();
        //別に↓はいらんかった…
        ///public MainThreadObservableCollection<string> ActionInfo { get; } = new MainThreadObservableCollection<string>();

        //ユーザ定義メンバ変数
        private TcpListener tcplistener = null;
        private TcpClient tcpClient = null;

        //ストリーム
        private NetworkStream networkStream = null;
        private StreamReader streamReader = null;
        private StreamWriter streamWriter = null;

#pragma warning disable 4014 //Task.Runの同期と非同期が混同してるが別に間違いじゃないので警告回避。

        /// <summary>
        /// 通信開始
        /// </summary>
        public async void Open()
        {
            string methodName = (IsServer ? "Server" : "Client") + "Open";
            try
            {
                IsOpen = true;
                //サーバー側の挙動
                if (IsServer)
                {
                    ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + "接続待機");
                    tcplistener = new TcpListener(IPAddress.Any, Port);
                    tcplistener.Start();
                    Task.Run(async () =>
                    {
                        //クライアントの要求があったら、接続を確立する(接続があるかtcplistener.Stop()が実行されるまで待機する)
                        await Task.Run(() => tcpClient = tcplistener.AcceptTcpClient());
                        ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + "接続完了");
                        networkStream = tcpClient.GetStream();
                        streamReader = new StreamReader(networkStream, Encoding.UTF8);
                        streamWriter = new StreamWriter(networkStream, Encoding.UTF8);
                        Task.Run(() => Receive());
                    });
                }
                //クライアント側の挙動
                if (!IsServer)
                {
                    ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + "接続待機");
                    //サーバーが見つかったら、接続を確立する(接続があるかタイムアウトまで待機する)
                    await Task.Run(() => tcpClient = new TcpClient(IpAddress, Port));
                    ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + "接続完了");
                    networkStream = tcpClient.GetStream();
                    streamReader = new StreamReader(networkStream, Encoding.UTF8);
                    streamWriter = new StreamWriter(networkStream, Encoding.UTF8);
                    Task.Run(() => Receive());
                }
            }
            catch (Exception ex)
            {
                ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + ex.Message);
                Close();
                IsOpen = false;
            }
        }

        /// <summary>
        /// 接続終了
        /// </summary>
        public void Close()
        {
            string methodName = (IsServer ? "Server" : "Client") + "Close";
            try
            {
                if (IsServer) tcplistener.Stop();
                if (!IsServer)
                {
                    if ((tcpClient != null) && tcpClient.Connected)
                    {
                        tcpClient.Close();
                        tcpClient.Dispose();
                    }
                }
                if (networkStream != null)
                {
                    networkStream.Close();
                    networkStream.Dispose();
                }
                tcplistener = null;
                tcpClient = null;
                networkStream = null;
                ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + "接続終了");
            }
            catch (Exception ex)
            {
                ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + ex.Message);
            }
            IsOpen = false;
        }

        /// <summary>
        /// 信号送信
        /// </summary>
        public void Send()
        {
            string methodName = (IsServer ? "Server" : "Client") + "Send";
            try
            {
                byte[] tmp = Encoding.UTF8.GetBytes(SendData);
                networkStream.Write(tmp, 0, tmp.Length);//送信 引数は(データ , データ書き込み開始位置 , 書き込むバイト数)
                ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + SendData);
            }
            catch (Exception ex)
            {
                ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + ex.Message);
            }
        }

        /// <summary>
        /// 信号受信(受信があるまで待機する)
        /// </summary>
        private async void Receive()
        {
            string methodName = (IsServer ? "Server" : "Client") + "Receive";
            try
            {
                await Task.Run(() =>
                {
                    while (true)
                    {
                        string res = null;
                        byte[] data = new byte[256];
                        string receiveData = string.Empty;
                        int bytes = networkStream.Read(data, 0, data.Length);
                        if (bytes == 0) break;
                        res = Encoding.UTF8.GetString(data, 0, bytes);
                        ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + res);
                    }
                    ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + "接続が遮断されました。");
                    Close();
                });
            }
            catch (Exception ex)
            {
                //遮断エラー(IOException(SocketExceptionじゃないのは何故…?))の場合はメッセージ不要
                if (!(ex is IOException)) ActionInfo.Insert(0, DateTime.Now.ToString("hh:mm:ss") + "," + methodName + "," + ex.Message);
            }
        }

#pragma warning restore 4014 //Task.Runの同期と非同期が混同してるが別に間違いじゃないので警告回避。

    }
}

クッソ長いけどご容赦を。MVVMならViewModelが減る代わりにModelが長くなるのは当然のことみたいです。

Collections.MainThreadObservableCollection

以下に関しては特に必要がないという事がかずきさんからの指摘で発覚したので取り消します。読み飛ばしてもらっていいです。


でもおっかしいなぁ…確かに例外吐いてたんだけどな…でも今やってみたらちゃんと動くしうーむ…

ListViewとBindingするModelの部分。本来ならObservableCollectionで良さそうなところを以下のように書いています。

public MainThreadObservableCollection<string> ActionInfo { get; } = new MainThreadObservableCollection<string>();

ここなんですが、非同期でObservableCollectionを用いたViewModels-Model間BindingをListViewで使うとエラーが発生します。非同期じゃない場合なら問題ないんですが、Collectionの非同期だけ特別みたいですね。こちらもかずきさんが解説されていました。
blog.okazuki.jp

要するに非同期で回すObservableCollectionはUIスレッドじゃないとダメよんという事ですね。Task.Run()しちゃってるので、状況したいではUIスレッド以外でやっちゃう事がある様子…。その為、ObservableCollectionを継承したDispatchObservableCollectionを作ってDispatcherを用いて常にUIスレッドで行うようにする手法を紹介されています。

…ただ、ここに書かれているDispatcherという奴はXamarin.Formsでは使えませんでした。/(^o^)\ナンテコッタイ
で、さらに調べたところ田淵さんがDevice Classについて説明してくれていました。
ytabuchi.hatenablog.com

Device.BeginInvokeOnMainThread
重要なやつ。バックグラウンドのスレッドから UI スレッドを触ることは出来ないので、UI スレッドに戻す処理を Device.BeginInvokeOnMainThread で包みましょう。これは iOS の InvokeOnMainThread、Android の RunOnUiThread、 Windows の Dispatcher.BeginInvoke と同等です。

つまり、代入するタイミングでDevice.BeginInvokeOnMainThreadを使えば、常にUIスレッドで更新できるようになるという事です。…という事で、ObservableCollectionを継承したMainThreadObservableCollectionを作成し、常にUIスレッドで代入できるようにしたという経緯になります。

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Text;
using System.Threading;
using Xamarin.Forms;

namespace SocketsTest.Collections
{
    /// <summary>
    /// UIスレッドに戻してから実行されるObservableCollection。
    /// Model→ViewModelなObservableCollectionをUIスレッド以外から使用した場合の例外発生に対応。
    /// </summary>
    /// <typeparam name="T">型指定</typeparam>
    public class MainThreadObservableCollection<T> : ObservableCollection<T>
    {
        protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
        {
            Device.BeginInvokeOnMainThread(() => {
                base.OnCollectionChanged(e);
            });
        }
    }
}

これをObservableCollectionの代わりに使えば常にUIスレッドを使ってくれるようになります。

挙動

Androidを2台用意して挙動を確認します。勿論両方を同一ネットワーク上に指定してください。
また、ポート番号は同じ値にする必要がありますが、番号次第では既に使われていたりするので注意が必要です。
手順は以下の通り。

  1. サーバー側をOPENする
  2. クライアント側をOPENする
  3. 片方で文字列を入力してSENDで送信する
  4. もう片方で送られた文字列が表示される
  5. CLOSEで閉じる

f:id:roamschemer:20181008100910g:plain

問題点

何故かUWP(Windows10)ではサーバー側でもクライアント側でも動きませんでした。UWPはPackage.appxmanifestで許可しないとサーバーになれないとの情報を頂いたのですがそれでもダメでした。ポートの設定とかアンチウイルスとかの影響かもしれないんですがまだよくわからないですね…。もうちょっと調査が必要です。

まとめ

かなり大変でしたがとりあえず動くものが出来て良かったです。また、このサンプルのお陰でMVVMをだいぶ理解できて来た気がしますね~。View,ViewModel,Modelで記述するべき事柄が完全に分離できるってのはこういう事だったんですねぇ…。確かに便利!今後はModelもちゃんと考慮した記述に変えていこうと思います。