Существует очень много приложений (на Android и на любых других ОС), которые взаимодействуют друг с другом с помощью соединения по сети. Например, к таким приложениям можно отнести любой месседжер: WhatsApp, Viber и т.д. Как правило, соединение между приложениями достигается путём использования сокетов.
Сокеты – это интерфейс, который позволяет связывать между собой различные устройства, находящиеся в одной сети. Сокеты бывают двух типов: клиент и сервер. Различие между ними заключается в том, что сервер прослушивает входящие соединения и обрабатывает поступающие запросы, а клиент к этому серверу подключается. Когда сервер запущен, он начинает прослушивать заданный порт на наличие входящих соединений. Клиент при подключении должен знать IP-адрес сервера и порт.
В связи с этим одним из основных применений сокетов служит использование их в качестве средства коммуникации.
В Android сокеты по умолчанию используют для передачи данных протокол TCP/IP вместо UDP. Важной особенностью этого протокола является гарантированная доставка пакетов с данными от одной конечной точки до другой, что делает этот протокол более надёжным. Протокол UDP не гарантирует доставку пакетов, поэтому этот протокол следует использовать, когда надёжность менее важна, чем скорость передачи.
Для реализации сокетов в Android используются классы Socket, предоставляющий методы для работы с клиентскими сокетами, и ServerSocket, предоставляющий методы для работы с серверными сокетами. Рассмотрим на примере нашего приложения “Эрудит“, как можно реализовать многопоточное приложение на основе сокетов. Суть приложения заключается в том, что в игре принимают участие судья (сервер) и 4 игрока (клиенты). Судья задаёт вопрос, после чего запускает таймер и игроки должны нажать кнопку, ответить на него.
Сервер
В роли сервера здесь будет выступать судья, поскольку он должен принимать ответы от всех команд и контролировать процесс игры. Для начала создадим класс SocketServer, наследующий от Thread.
public class SocketServer extends Thread { private boolean running = false; // флаг для проверки, запущен ли сервер private ServerSocket serverSocket; // экземпляр класса ServerSocket public SocketServer() { } }
Примечание: В этом классе неспроста используется наследование от Thread, поскольку операции, связанные с сетью, следует выполнять в отдельном от главного потоке. В противном случае приложение будет крашиться с исключением android.os.NetworkOnMainThreadException. По этой причине здесь и далее вся работа с сокетами будет выполняться в потоках.
Данный класс будет служить “обёрткой” для ServerSocket, чтобы можно было удобнее взаимодействовать с ним. Поскольку мы используем наследование от Thread, необходимо реализовать метод run(), внутри которого будет помещена логика работы сервера.
private void runServer() { running = true; try { // создаём серверный сокет, он будет прослушивать порт на наличие запросов serverSocket = new ServerSocket(Constants.SERVER_PORT); while (running) { // запускаем бесконечный цикл, внутри которого сокет будет слушать соединения и обрабатывать их // создаем клиентский сокет, метод accept() создаёт экземпляр Socket при новом подключении Socket client = serverSocket.accept(); } } catch (Exception e) { e.printStackTrace(); } } @Override public void run() { super.run(); runServer(); }
Задача сервера заключается в том, чтобы слушать заданный порт и принимать входящие подключения. Однако поскольку у нас должно быть 4 клиента, их нужно как-то различать. Для этих целей создадим класс UserManager, целью которого будет связывание сокета, полученного в результате метода accept() с пользователем, который установил соединение.
public class UserManager extends Thread { private User user; // экземпляр класса User, хранящий информацию о пользователе private Socket socket; // сокет, созданный при подключении пользователя private PrintWriter bufferSender; private boolean running; // флаг для проверки, запущен ли сокет private UserManagerDelegate managerDelegate; // экземпляр интерфейса UserManagerDelegate public UserManager(Socket socket, UserManagerDelegate managerDelegate) { this.user = new User(); this.socket = socket; this.managerDelegate = managerDelegate; running = true; } public User getUser() { return user; } public Socket getSocket() { return socket; } @Override public void run() { super.run(); try { // отправляем сообщение клиенту bufferSender = new PrintWriter(new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())), true); // читаем сообщение от клиента BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); // в бесконечном цикле ждём сообщения от клиента и смотрим, что там while (running) { String message = null; try { message = in.readLine(); } catch (IOException e) { } // проверка на команды if (hasCommand(message)) { continue; } if (message != null && managerDelegate != null) { user.setMessage(message); // сохраняем сообщение managerDelegate.messageReceived(user, null); // уведомляем сервер о сообщении } } } catch (Exception e) { e.printStackTrace(); } } public void close() { running = false; if (bufferSender != null) { bufferSender.flush(); bufferSender.close(); bufferSender = null; } try { socket.close(); } catch (Exception e) { e.printStackTrace(); } socket = null; } public void sendMessage(String message) { if (bufferSender != null && !bufferSender.checkError()) { bufferSender.println(message); bufferSender.flush(); } } public boolean hasCommand(String message) { if (message != null) { if (message.contains(Constants.CLOSED_CONNECTION)) { close(); managerDelegate.userDisconnected(this, user.getUsername()); return true; } else if (message.contains(Constants.LOGIN_NAME)) { user.setUsername(message.replaceAll(Constants.LOGIN_NAME, "")); user.setUserID(socket.getPort()); managerDelegate.userConnected(user); return true; } else if (message.contains(Constants.PING)) { return true; } } return false; } // интерфейс, который передает результаты операций в SocketServer public interface UserManagerDelegate { void userConnected(User connectedUser); void userDisconnected(UserManager userManager, String username); void messageReceived(User fromUser, User toUser); } }
Здесь аналогичным образов в потоке запускаем созданный сокет и ставим его на прослушивание. Параллельно с этим создаём экземпляр класса User, код которого представлен ниже. Он служит для хранения данных о пользователях и их сообщениях
public class User { private String username; // имя игрока private String message; // последнее сообщение private int userID; // идентификатор игрока (в данном случае это порт сокета) public User() { } public int getUserID() { return userID; } public void setUserID(int userID) { this.userID = userID; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } }
После того, как сообщение было получено, проверяется наличие в нём команд в методе hasCommand(). Например, команда LOGIN_NAME сообщает никнейм подключившегося игрока, а команда CLOSED_CONNECTION – о закрытии соединения. Если никакой команды нет – просто передаём сообщение через интерфейс.
При подключении нового пользователя передаём в интерфейс данные о нём с помощью метода userConnected(), аналогично при дисконнекте вызываем userDisconnected().
Метод close() закрывает соединение с клиентом.
Метод sendMessage() отправляет сообщение клиенту.
Теперь пробросим интерфейс в класс SocketServer.
public class SocketServer extends Thread implements UserManager.UserManagerDelegate { private boolean running = false; // флаг, определяющий, запущен ли сервер private ServerSocket serverSocket; private ArrayList<UserManager> connectedUsers; // список подключенных игроков public SocketServer(OnMessageReceived messageListener) { this.messageListener = messageListener; connectedUsers = new ArrayList<>(); } private void runServer() { running = true; try { serverSocket = new ServerSocket(Constants.SERVER_PORT); while (running) { Socket client = serverSocket.accept(); UserManager userManager = new UserManager(client, this); connectedUsers.add(userManager); userManager.start(); } } catch (Exception e) { e.printStackTrace(); } } @Override public void run() { super.run(); runServer(); } @Override public void userConnected(User connectedUser) { } @Override public void userDisconnected(UserManager userManager, String username) { connectedUsers.remove(userManager); } @Override public void messageReceived(User fromUser, User toUser) { sendMessage(fromUser); } }
В SocketServer создадим экземпляр класса UserManager и список, содержащий объекты этого класса. При создании нового сокета он передаётся в конструктор UserManager, после чего запускается поток.
Чтобы остановить сервер, напишем метод close() со следующим кодом.
public void close() { if (connectedUsers != null) { // закрытие всех соединений с клиентами for (UserManager userManager : connectedUsers) { userManager.close(); } } running = false; // закрытие сервера try { serverSocket.close(); } catch (Exception e) { e.printStackTrace(); } serverSocket = null; }
Для начала здесь нужно закрыть все соединения с клиентами, после этого остановить прослушивание и остановить сокет методом close().
Отправка сообщений клиентам происходит следующим образом.
public void sendMessage(User user) { if (connectedUsers != null) { for (UserManager userManager : connectedUsers) { if (userManager.getUser().getUserID() != user.getUserID()) { userManager.sendMessage(user.getMessage()); // если идентификатор пользователя не равен идентификатору отправившего сообщение - отправляем ответ } } } } public void sendMessageTo(int id, String msg) { if (connectedUsers != null) { for (UserManager userManager : connectedUsers) { if (userManager.getUser().getUserID() == id) { userManager.sendMessage(msg); // если идентификатор пользователя равен заданному - отправляем ответ } } } } public void sendToAll(String msg) { if (connectedUsers != null) { for (UserManager userManager : connectedUsers) { userManager.sendMessage(msg); // ищем всех пользователей в списке и отправляем ответ } } }
Метод sendMessage() отправляет сообщение всем, кроме выбранного пользователя. Он используется, когда отправляется сообщение о том, что отвечает команда N, другим командам.
Метод sendMessageTo() отправляет сообщение только одному пользователю, поиск пользователя происходит по идентификатору.
Метод sendToAll() отправляет сообщение всем подключённым пользователям.
Теперь нужно создать интерфейс, который будет передавать данные в основной поток. Для этого создадим интерфейс со следующим кодом.
public interface OnMessageReceived { void messageReceived(String message, User from); // отправка сообщения в UI-поток void updatePlayerList(ArrayList<UserManager> connectedUsers); // обновление списка при подключении\отключении } private OnMessageReceived messageListener; ... @Override public void userConnected(User connectedUser) { messageListener.updatePlayerList(connectedUsers); } @Override public void userDisconnected(UserManager userManager, String username) { connectedUsers.remove(userManager); messageListener.updatePlayerList(connectedUsers); } @Override public void messageReceived(User fromUser, User toUser) { messageListener.messageReceived(fromUser.getMessage(), fromUser); sendMessage(fromUser); }
Теперь в главном потоке нужно создать экземпляр класса SocketServer и пробросить интерфейс.
public class AdminActivity extends AppCompatActivity { private SocketServer mServer; ... @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_admin); ... mServer = new SocketServer(new SocketServer.OnMessageReceived() { @Override public void messageReceived(String message, User from) { parseMessage(message, from); } @Override public void updatePlayerList(ArrayList connectedUsers) { updatePlayer(connectedUsers); } }); mServer.start(); } ... private void updatePlayer(final ArrayList connectedUsers) { runOnUiThread(new Runnable() { @Override public void run() { users.setText(""); } }); if (connectedUsers.size() != 0) { if (connectedUsers.size() == 1) { runOnUiThread(new Runnable() { @Override public void run() { users.setText(connectedUsers.get(0).getUser().getUsername()); } }); } else { final StringBuilder builder = new StringBuilder(); for (UserManager user : connectedUsers) { builder.append(user.getUser().getUsername()); builder.append("\n"); } runOnUiThread(new Runnable() { @Override public void run() { users.setText(builder.toString()); } }); } } } private void parseMessage(final String message, final User from) { runOnUiThread(new Runnable() { @Override public void run() { // проверяем, отвечает ли сейчас кто-либо if (!TextUtils.isEmpty(name) && isAnswered) { if (!name.equals(from.getUsername())) { new Thread(new Runnable() { @Override public void run() { mServer.sendMessageTo(from.getUserID(), Constants.ANSWERED); } }).start(); } return; } // проверяем, запущен ли таймер if (!isTimerStarted) { msg.setText(Constants.FALSE_START + " - " + from.getUsername()); new Thread(new Runnable() { @Override public void run() { mServer.sendMessageTo(from.getUserID(), Constants.FALSE_START); } }).start(); return; } // останавливаем таймер isTimerStarted = false; countDownTimer.cancel(); msg.setText(message); ibTimer.setEnabled(false); // смотрим, какая команда отвечает, и зажигаем соответствующую кнопку if (from != null && !isAnswered) { switch (from.getUsername()) { case "Команда 1": { btn_team1.setBackgroundResource(R.drawable.button_pressed); break; } case "Команда 2": { btn_team2.setBackgroundResource(R.drawable.button_pressed); break; } case "Команда 3": { btn_team3.setBackgroundResource(R.drawable.button_pressed); break; } case "Команда 4": { btn_team4.setBackgroundResource(R.drawable.button_pressed); break; } } name = from.getUsername(); isAnswered = true; // устанавливаем флаг, сообщающий о том, что в данный момент дается ответ new Thread(new Runnable() { @Override public void run() { mServer.sendMessageTo(from.getUserID(), Constants.DONE); } }).start(); } } }); } }
Метод updatePlayer() обновляет список подключенных игроков при подключении\отключении кого-либо из игроков.
Примечание: если из потока нужно обновить элементы интерфейса, то следует вызывать runOnUiThread(), который позволяет выполнять код в UI-потоке.
Метод parseMessage() определяет, что за сообщение пришло. Сначала следует проверка на то, что на вопрос уже даётся ответ. В этом случае игроку, отправившему это сообщение, отправляется ответ о том, что на вопрос уже даётся ответ. После этого идёт проверка на то, запущен ли таймер. Если таймер не был запущен, то необходимо отправить игроку сообщение о фальстарте. После всех проверок определяется, какой пользователь отправил сообщение и загорается соответствующая кнопка на экране.
Примечание: поскольку отправлять сообщения в UI-потоке нельзя, здесь используется следующая конструкция.
new Thread(new Runnable() { @Override public void run() { mServer.sendMessageTo(message); } }).start();
Клиент
Клиент это игрок, который отвечает на вопрос, заданный судьёй. После сигнала он должен нажать на кнопку, чтобы дать ответ на вопрос.
Для реализации клиентского сокета создадим класс SocketClient.
public class SocketClient { private String mServerMessage; private OnMessageReceived mMessageListener = null; private boolean mRun = false; // флаг, определяющий, запущен ли сервер private PrintWriter mBufferOut; private BufferedReader mBufferIn; private Socket socket; private String address; public SocketClient(String address, OnMessageReceived listener) { this.address = address; mMessageListener = listener; } public void sendMessage(String message) { if (mBufferOut != null && !mBufferOut.checkError()) { mBufferOut.println(message); mBufferOut.flush(); } } public void stopClient() { sendMessage(Constants.CLOSED_CONNECTION); mRun = false; if (mBufferOut != null) { mBufferOut.flush(); mBufferOut.close(); } mMessageListener = null; mBufferIn = null; mBufferOut = null; mServerMessage = null; } public void run(String player) { try { InetAddress serverAddr = InetAddress.getByName(address); try { socket = new Socket(serverAddr, Constants.SERVER_PORT); mRun = true; mBufferOut = new PrintWriter(new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())), true); mBufferIn = new BufferedReader(new InputStreamReader(socket.getInputStream())); sendMessage(Constants.LOGIN_NAME + player); // отправляем название команды mMessageListener.onConnected(); // ждем ответа while (mRun) { if (mBufferOut.checkError()) { mRun = false; } mServerMessage = mBufferIn.readLine(); if (mServerMessage != null && mMessageListener != null) { mMessageListener.messageReceived(mServerMessage); } } } catch (Exception e) { } finally { if (socket != null && socket.isConnected()) { socket.close(); } } } catch (Exception e) { } } public boolean isConnected() { return socket != null && socket.isConnected(); } public boolean isRunning() { return mRun; } public interface OnMessageReceived { void messageReceived(String message); void onConnected(); } }
Метод run() запускает клиент и содержит логику работы сокета. Внутри него создаётся экземпляр класса Socket, который подключается к конечной точке с заданными IP-адресом и портом. Затем вызывается метод onConnected() интерфейса OnMessageReceived, уведомляющий главный поток о том, что сокет установил соединение. После этого вызывается метод sendMessage(), отправляющий сообщения на сервер, в котором передаётся команда LOGIN_NAME и название команды. После этого запускается бесконечный цикл, в котором клиент ждёт сообщения от сервера. Получив сообщение, происходит вызов метода messageReceived() интерфейса OnMessageReceived, который передает сообщение в главный поток.
Метод isConnected() проверяет, подключился ли клиент к серверу.
Метод isRunning() проверяет, запущен ли клиент.
Метод stopClient() разрывает соединение с сервером, предварительно посылая сообщение с командой CLOSED_CONNECTION.
Теперь создадим на активности экземпляр класса SocketClient и пробросим интерфейс.
public class ClientActivity extends AppCompatActivity { private SocketClient mTcpClient; ... @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_client); ... showPlayerDialog(); // запускаем диалог с выбором команды } private void connectToServer() { if (name != null) { new Thread(new Runnable() { @Override public void run() { mTcpClient = new SocketClient(address, new SocketClient.OnMessageReceived() { @Override public void onConnected() { runOnUiThread(new Runnable() { @Override public void run() { sendPing(); // начинаем посылать пинги } }); } @Override public void messageReceived(final String message) { switch (message) { case Constants.ANSWERED: { runOnUiThread(new Runnable() { @Override public void run() { buttonAnswer.setBackgroundResource(R.drawable.button_normal); } }); break; } case Constants.BEEP: { playBeep(); break; } case Constants.RESET: { runOnUiThread(new Runnable() { @Override public void run() { response.setText(""); buttonAnswer.setBackgroundResource(R.drawable.button_normal); } }); break; } case Constants.FALSE_START: { runOnUiThread(new Runnable() { @Override public void run() { response.setText(Constants.FALSE_START); buttonAnswer.setBackgroundResource(R.drawable.button_normal); } }); break; } default: { runOnUiThread(new Runnable() { @Override public void run() { response.setText(message); } }); break; } } } }); mTcpClient.run(name); } }).start(); } } private void sendPing() { Runnable runnable = new Runnable() { @Override public void run() { if (mTcpClient != null) { if (!mTcpClient.isRunning()) { connectToServer(); } else { mTcpClient.sendMessage(Constants.PING); } } handler.postDelayed(this, 2000); } }; handler.post(runnable); } private void showPlayerDialog() { AlertDialog.Builder b = new AlertDialog.Builder(this); b.setTitle("Выберите свою команду"); b.setCancelable(false); String[] types = { "Команда 1", "Команда 2", "Команда 3", "Команда 4" }; b.setItems(types, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); switch (which) { case 0: name = "Команда 1"; tv_info.setText(tv_info.getText() + name); break; case 1: name = "Команда 2"; tv_info.setText(tv_info.getText() + name); break; case 2: name = "Команда 3"; tv_info.setText(tv_info.getText() + name); break; case 3: name = "Команда 4"; tv_info.setText(tv_info.getText() + name); break; } showTeamDialog(); // запускаем диалог с вводом IP-адреса сервера } }); b.show(); } private void showTeamDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(this); View view = getLayoutInflater().inflate(R.layout.dialog_client, null); builder.setView(view); final EditText et_address = view.findViewById(R.id.addressEditText); builder.setCancelable(false); builder.setPositiveButton("ОК", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { address = et_address.getText().toString(); tv_info.setText(tv_info.getText() + " | " + address); connectToServer(); // запускаем подключение к серверу } }); AlertDialog dialog = builder.create(); dialog.show(); } private void playBeep() { try { MediaPlayer m = new MediaPlayer(); if (m.isPlaying()) { m.stop(); m.release(); m = new MediaPlayer(); } AssetFileDescriptor descriptor = getAssets().openFd("signal.mp3"); m.setDataSource(descriptor.getFileDescriptor(), descriptor.getStartOffset(), 10000); descriptor.close(); m.prepare(); m.setVolume(1f, 1f); m.start(); } catch (Exception e) { e.printStackTrace(); } } }
После того, как будут заданы название команды и IP-адрес сервера, запустится метод connectToServer(), создающий поток, в котором инициализируется экземпляр SocketClient. Внутри него реализован интерфейс с методами onConnected() и messageReceived().
В методе onConnected() мы получаем событие, что клиент установил соединение, и вызываем метод sendPing(), который будет каждые 2 секунды посылать на сервер пинги. Это необходимо для более надежного соединения, поскольку отследить на стороне клиента, что сервер прекратил работу, может быть весьма затруднительно. В случае, если соединение теряется, начинает вызываться метод connectToServer() до тех пор, пока соединение не восстановится.
В методе messageReceived() определяется, какое сообщение пришло от сервера, и в зависимости от этого выполняются соответствующие операции.
- ANSWERED – уведомляет о том, что она вопрос уже отвечает другая команда. Возвращает кнопку в исходное состояние.
- BEEP – сообщает о том, что таймер был запущен и нужно воспроизвести сигнал. Для этого вызывается метод playBeep(), который с помощью класса MediaPlayer воспроизводит MP3-файл, хранящийся в папке Assets.
- RESET – сбрасывает все данные (поле ответа от сервера, состояние кнопки). Это сообщение приходит, когда какая-либо команда ответила на вопрос и нужно восстановить все состояния для нового вопроса.
- FALSE_START – сообщает игроку, что он нажал кнопку раньше, чем был запущен таймер. Возвращает кнопку в исходное состояние.
- По умолчанию: просто выводит сообщение от сервера на экран.
Когда вызывается метод активности onPause(), клиент останавливается с помощью следующего кода.
@Override protected void onPause() { if (mTcpClient != null) { new Thread(new Runnable() { @Override public void run() { mTcpClient.stopClient(); mTcpClient = null; } }).start(); } super.onPause(); }
При возврате в активность восстановить соединение можно вручную, нажать на кнопку переподключения, которая вызовет метод connectToServer().
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_client); ... ImageButton ib_reconnect = findViewById(R.id.ib_reconnect); ib_reconnect.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { if (mTcpClient == null || !mTcpClient.isConnected() || !mTcpClient.isRunning()) { connectToServer(); } } });
Сообщение об ответе на сервер посылается с помощью метода sendAnswer(), который вызывается при нажатии на кнопку.
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_client); ... buttonAnswer = findViewById(R.id.answer_button); buttonAnswer.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View arg0) { sendAnswer(); } }); } private void sendAnswer() { final String message = "Отвечает команда: " + name; if (mTcpClient != null && mTcpClient.isConnected()) { new Thread(new Runnable() { @Override public void run() { mTcpClient.sendMessage(message); } }).start(); buttonAnswer.setBackgroundResource(R.drawable.button_pressed); } }
Таким образом, в результате приведенного выше кода мы создали приложение, работающее на сокетах, которые обеспечивают взаимодействие между сервером и несколькими клиентами.