воскресенье, 9 декабря 2012 г.

ClientSocket под Android. Клиент для взаимодействия приложений через интернет.

Доброго всем! Решил поделиться своими наработками в области создания сетевых приложений под Андроид. Данное сообщение не относится непосредственно к AndEngine, но имеет прямое отношение к геймдеву, ведь каждому игроделу когда-нибудь приходит в голову идея организовать взаимодействие игроков через интернет (настольные игры, общение, пошаговые стратегии и прочее).
Сразу оговорюсь, что приведенный здесь класс не обеспечивает "прямого" взаимодействия пользователей. Между пользователями необходимо наличие веб-сервера на котором должна быть реализована логика аутентификации, игровая логика и который будет координировать команды и события от клиентов.
В процессе портирования одной популярной настольной игры под Android, мы решили прикрутить к ней возможность игры через интернет.
Задача стояла следующая: получить инструмент, позволяющий легко реализовывать пошаговый протокол игры, имеющий события подключения, отключения, перехват ошибок,  ну и конечно отправки/приема данных. Я намеренно пишу "получить", потому что не являюсь хардкорным программером и если нахожу хорошее опенсурс-решение со свободной лицензией, то ничуть не стесняюсь его использовать. Такого же мнения придерживаюсь и для своего кода в этом блоге: если кому-то что-то глянется - берите не стесняйтесь. =)
Итак, начали мы с активных поисков готового кода. Через пару дней скитаний по интернету стало понятно, что устойчивого и законченного клиента для WEB для нас никто не выложил =). Конечно, нашлось немало статей по созданию таких клиентов, но все они были настолько печальны, что их юзабельность сводилась к нулю.
Я не могу привести здесь все источники из которых мы брали информацию, во-первых их очень много, а во-вторых ни один из них не стал для нас отправной точкой, поэтому, без лишних слов привожу здесь код который у нас получился из собранной инфы и собственных умозаключений.

package com.expedition107.mytreasures;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.net.UnknownHostException;

import android.os.Handler;
import android.util.Log;

public class ClientSocket {

 private Handler handler = new Handler();

 private static String mHostIpAddress;
 private static int mPort;

 private Thread mServiceThread;
 private Runnable mServiceRunnable;

 private Socket mSocket;

 private volatile boolean isConnected = false;
 private int mSocketStatus = 0;// 0-disconnected, 1- connection, 2-
         // io_pending, 3 - write out, 4 - read in, -1 - unknown state
 private volatile String mOutString = "";
 
 private volatile boolean mIsNewData;

 public ClientSocket(String pHostIp, int pPort) {
  mHostIpAddress = pHostIp;
  mPort = pPort;

 }

 public void connect(final int pTimeoutMillisecs) {
  if (mSocketStatus != 0) {
   return;
  }
  mServiceRunnable = new Runnable() {
   @Override
   public void run() {
    mSocketStatus = 1; //соединяемся
    // connect
    try {
     mSocket = new Socket();
     mSocket.connect(new InetSocketAddress(mHostIpAddress,mPort), pTimeoutMillisecs);
     mSocket.setKeepAlive(true);
     setConnected(true);
     onConnect();
     mSocketStatus = 2;//уходим в прослушку сокета
     char[] inBuff = new char[1024];
     while (isConnected()) {
      String tmpSendData = getSendData(); 
      if (!tmpSendData.equals("")) {
       mSocketStatus = 3;//пишем в сокет
       PrintWriter out = new PrintWriter(
         new BufferedWriter(new OutputStreamWriter(
           mSocket.getOutputStream())), true);
       
       out.println(tmpSendData);
       handler.post(new Runnable() {
        @Override
        public void run() {
         onSendData();
        }
       });
       
       clearSendData(); 
      }
      
      mSocketStatus = 4;//читаем из сокета
      BufferedReader in = new BufferedReader(
        new InputStreamReader(mSocket.getInputStream()), 1024);
      String line = "";
      if (in.ready()) {      
       int length = in.read(inBuff);
       while ((length) != -1) {
        for (int i = 0; i < length; i++) {
         line = line + inBuff[i];
        }
        length = -1;
        if (in.ready()){
         length = in.read(inBuff);
        }
       } 
       final String inStr = line;
       handler.post(new Runnable() {
        @Override
        public void run() {
         onReadData(inStr);
        }
       });
       
      }
     }
     mSocketStatus = 0;//отсоединяемся
     mSocket.close();
     handler.post(new Runnable() {
      @Override
      public void run() {
       onDisconnect();
      }
     });
     

    } catch (UnknownHostException e) {
     final UnknownHostException _e = e;
     handler.post(new Runnable() {
      @Override
      public void run() {
       mSocketStatus = -1;
       onSocketError(mSocketStatus,_e);
       mSocketStatus = 0;
      }
     });
     e.printStackTrace();
    } catch (SocketException e) {
     final SocketException _e = e;
     handler.post(new Runnable() {
      @Override
      public void run() {
       onSocketError(mSocketStatus, _e);
       mSocketStatus = 0;
      }
     });
     e.printStackTrace();
    } catch (IOException e) {
     final IOException _e = e;
     handler.post(new Runnable() {
      @Override
      public void run() {
       onSocketError(mSocketStatus, _e);
       mSocketStatus = 0;
      }
     });
     e.printStackTrace();
    }
   }
  };
  mServiceThread = new Thread(mServiceRunnable);
  mServiceThread.start();
 }
 
 //эти методы вызываются из UI-потока
 public void disconnect() {
  setConnected(false);
 }


 public void onConnect() {
  
 }

 public void onDisconnect() {
  
  setConnected(false);
 }

 public void onSocketError(int pSocketStatus, Exception e) {
  
 }

 public void onReadData(String pDataString) {
  
 }

 public void onSendData() {
  
 }

 //синхронизированные методы чтения/записи
 protected synchronized boolean isConnected(){
  return this.isConnected;
 }
 
 private synchronized boolean setConnected(boolean pIsConnected){
  this.isConnected = pIsConnected;
  return this.isConnected;
 }
 //этот метод вызывается из UI-потока
 protected synchronized void send(String pOutString){
  mOutString = pOutString;
  mIsNewData = true;
 }
 
 private synchronized String getSendData(){
  mIsNewData = false;
  return mOutString;
 }
 
 private synchronized void clearSendData(){
  if (!mIsNewData)
   mOutString = "";
 }
}

И, как обычно, немного пояснений.
Работа сокета по чтению и записи данных должна происходить в отдельном потоке и ни в коем случае не в потоке UI., именно поэтому, чтобы получить доступ из UI к данным (а ведь это нам и нужно) которые приняты/переданы мы создаем Handler и работаем через него.
Синхронизированные методы нужны для синхронизации доступа к volatile переменным, т.е. переменным которые могут использоваться и потоком UI и потоком сокета.
В коде фигурирует переменная mSocketStatus использование которой обоснованно только нашими способами отладки =).

Использование класса заключается в перекрытии его методов прямо в UI потоке:

Чтение данных и обработка ошибок:
final ClientSocket cs = new ClientSocket(serverIpAddress, 7777){
         @Override
         public void onConnect(){
             //Например показать Toast уведомление
           
         }
         @Override
         public void onDisconnect(){
             //обязательно вызвать метод супера
             //иначе разрыва связи не произойдет
                    super.onDisconnect();
             //тут ваш код
           
         }
         @Override
         public void onReadData(String pStr){
             //В pStr хранятся принятые данные
                    textView.setText(textView.getText()+pStr+"\n");
           
         }
         
         @Override
         public void onSocketError(int pSocketStatus, Exception e) {
          Log.v("game", e.getMessage());
         }
        };

Подключение к серверу:
...
cs.connect(5000);
...  

Отправка данных:
...
cs.send("СТРОКА ДАННЫХ НА ОТПРАВКУ");
...

Отключение от сервера:
...
cs.disconnect();
...  

Отсюда видно, что с этим классом не надо заморачиваться с работой сокетов, а сразу приступить к реализации своего протокола, а при наличии хорошего стабильного соединения - не только пошагового. Здесь имеется возможность контролировать момент подключения/отключения к серверу, ошибки сокета, отправку и прием данных.
Протокол для своей игрушки мы реализовали на базе XML. Советую использовать вот этот класс для парсинга, очень помогает не запутаться в ветках.
PS Не воспринимайте данный класс как панацею, хотя он и вполне работоспособен, небольшие сомнения все-таки вызывает отправка/очистка данных и если у кого-то появится желание по усовершенствованию данного класса мы будем рады совету или подсказке. Изменения я постараюсь сразу же внести.
Спасибо за внимание!