Flutter/Dart - 채팅 앱 만들기(4) - 앱에서 소켓 서버와 통신하여 채팅 기능 구현하기

반응형

개요

웹 소켓 서버에 대한 기능은 이전 포스팅으로 분리하였으므로,

이번에는 채팅 앱 클라이언트에서 웹 소켓 서버와 통신하고

실제로 실시간 채팅이 이루어지는 기능을 상호작용할 수 있도록 해보자.

 

[ 시리즈 포스팅 내용 보기 ]

 

이번 포스팅에서는 아래와 같은 내용을 다룬다.

 

클라이언트에서 메시지를 보내는 방법

  • 단순히 모든 클라이언트에게 메시지를 보내는 방법
  • 특정 클라이언트에게 메시지를 보내는 방법
    • 포스팅에서는 '귓속말'이라는 기능으로 구현하였다.

[ 각각의 클라이언트 1, 2, 3의 유저가 접속하여 메시지를 나누고 1과 2의 유저가 단 둘만의 메시지를 전달하는 예시 화면 ]

 

 

 

포스팅에서 다루는 플러터 채팅 앱은 깃허브에서 다운로드 할 수 있다.

 

GitHub - luvris2/flutter_chatting_app

Contribute to luvris2/flutter_chatting_app development by creating an account on GitHub.

github.com


웹소켓 클라이언트

구조 설계

[ 파일 구조 ]

  • main.dart
    • 플러터 앱이 실행되는 메인 함수를 포함하는 파일
    • 사용자의 식별을 위해 닉네임을 입력 받아 채팅 페이지로 이동하는 페이지를 포함하고 있다.
  • socket.dart
    • 웹 소켓 서버에 연결하고, 소켓 서버 데이터를 송수신하는 기능을 포함하고 있다.
  • chat 폴더
    • chat_main.dart
      • 채팅 페이지를 구성할 메인 뼈대 역할을 하는 파일
      • 해당 페이지에서 채팅 내용을 입력할 영역과 채팅 내용을 보여줄 영역을 지정한다.
      • 클라이언트로 전달 받은 데이터를 하위 요소들에게 데이터를 전달하는 역할을 한다.
      • 또한, setState 함수를 하위 요소로 전달하여 상태값을 각 클래스와 공유한다.
    • chat_area.dart
      • 채팅 내용을 보여줄 영역을 구성하는 파일
      • 서버에서 전달 받은 데이터를 토대로 채팅 메시지 내용을 구성하는 역할을 한다.
      • 메시지의 내용은 생성자를 통해 chat_main.dart로부터 전달 받는다.
    • input_text_area.dart
      • 채팅 내용을 입력하는 기능으로 구성된 파일
      • 작성한 메시지 내용을 사용자와 상호작용하여 서버로 요청하는 역할을 한다.
      • 사용자의 이름과 메시지의 내용을 생성자를 통해 chat_main.dart으로부터 전달받고, 해당 값을 전달받은 setState(포스팅에서는 updateMessage로 지정) 함수를 통해 값을 공유한다.

 

[ 연결 구조 ]

  • main.dart
    • chat_main.dart
      • chat_area.dart
      • input_text_area.dart
        • socket.dart

기능 구현

main.dart (MainPage)

메인 함수 (main)

  • 메인 함수를 통해 메인 파일의 메인 페이지를 처음으로 보여주도록 한다.
void main() async {
  runApp(
    const MaterialApp(
      home: MainPage(), //ChatMainPage(),
    ),
  );
}

 

메인 페이지 (MainPage)

  • 사용자의 이름을 입력 받고, 확인 버튼을 누르면 채팅 페이지로 이동하는 기능을 하는 페이지
  • 텍스트 에디팅 컨트롤러로 입력 받은 사용자 이름을 chat_main.dart 파일의 ChatMainPage 클래스로 값을 넘겨준다.
class MainPage extends StatelessWidget {
  const MainPage({super.key});

  @override
  Widget build(BuildContext context) {
    // 텍스트필드 컨트롤러
    TextEditingController textEditingController = TextEditingController();

    return Scaffold(
      body: Center(
        child: Container(
          width: 250,
          height: 200,
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(20),
            border: Border.all(
              color: Colors.grey,
              width: 2,
            ),
          ),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              // 유저 이름 입력
              SizedBox(
                width: 200,
                child: TextField(
                  controller: textEditingController,
                  decoration: const InputDecoration(
                    label: Center(child: Text("사용자 이름 입력")),
                  ),
                  textAlign: TextAlign.center,
                ),
              ),
              ElevatedButton(
                onPressed: () {
                  String txtValue = textEditingController.text;
                  txtValue = txtValue.trim();
                  if (txtValue != "") {
                    Navigator.push(context,
                        MaterialPageRoute(builder: (context) {
                      return ChatMainPage(
                        username: txtValue,
                      );
                    }));
                  }
                },
                child: const Text("확인"),
              )
            ],
          ),
        ),
      ),
    );
  }
}

socket.dart (FlutterWebSocket)

클라이언트가 웹 소켓 서버에 연결하고, 메시지 데이터를 보내기 위한 클래스를 정의한다.

 

웹 소켓 서버에 연결

웹 소켓 서버를 연결하기 위한 함수 getSocket을 정의한다.

반환 값으로 연결된 소켓 인스턴스를 반환한다.

  List messageList = [];
  String SERVER = "ws://192.168.10.103:4001";

  // 웹 소켓 서버 연결
  Future<WebSocket> getSocket() async {
    WebSocket socket = await WebSocket.connect(SERVER);
    return socket;
  }

 

소켓 서버에 데이터 송신

소켓 서버에 데이터를 보낼 함수 addMessage를 정의한다.

해당 함수에서는 JSON 데이터로 내보내기 위해 맵 형식으로 구성한다.

웹 소켓 서버에 데이터를 보내는 구성 형식은 다음과 같다.

  • username : 서버로 보낼 식별 가능한 유저의 이름
  • message : 서버로 보낼 메시지 내용
  • type : 서버에서 기능을 구행하기 위한 유형
    • 유형에는 3가지로 나누었다.
    • init : 클라이언트 접속 정보 초기화, 맨 처음 소켓 서버에 연결할 때 접속 정보를 넘겨주기 위해 사용한다.
    • all : 모든 클라이언트에게 메시지 전송
    • whisper : 특정 클라이언트에게 메시지 전송, '|' 기호를 구분자로 처리하여 뒤의 문자열을 특정 클라이언트의 정보를 기입하도록 하였다.
  // 소켓 서버에 데이터 송신
  addMessage(socket, username, message, type) {
    Map<String, dynamic> data = {
      'username': username,
      'message': message,
      'type': type,
      // [type]
      //    - init    :   클라이언트 접속 정보 초기화
      //    - all     :   모든 클라이언트에게 메시지 전송
      //    - whisper|username :   특정 클라이언트에게 메시지 전송
    };
    print("[socket.dart] 메시지 전송 : $username : $message");
    socket?.add(jsonEncode(data));
  }

 

chat_main.dart (ChatMainPage)

상태 값을 공유할 변수 및 함수 선언

  // 메시지 내용을 저장하는 변수
  List messageList = [];

  // 메시지 내용을 setState 함수를 통해 상태를 업데이트하는 함수
  void setStateMessage(data) {
    print("[chat_main.dart] (setStateMessage) 업데이트 할 값 : $data");
    setState(() => messageList.add(data));
  }

 

채팅 영역과 메시지 내용 입력 영역 구분 및 각 생성자 데이터 할당

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          children: <Widget>[
            // 메시지 내용 표시 영역
            ChatArea(
              messageList: messageList,
            ),
            // 메시지 입력 영역
            InputTextArea(
              username: widget.username,
              messageList: messageList,
              updateMessage: setStateMessage,
            )
          ],
        ),
      ),
    );
  }

input_text_area.dart (InputTextArea)

웹소켓 서버 연결 및 설정

웹소켓 서버를 연결하기 위해 위에서 정의한 FlutterWebSocket 클래스를 이용하여 소켓 서버에 연결한다.

  1. 위젯이 초기화 될 때(initState) 소켓 서버를 연결하기 위해 FlutterWebSocket 클래스의 getSocket() 메서드를 이용하여 소켓 서버를 연결한다.
  2. 소켓 인스턴스를 반환받은 소켓의 listen 함수를 이용하여 메시지를 수신 받도록 한다.
  3. 서버에서 메시지를 수신 받을 경우, 메시지의 값을 업데이트한다. (setState)
    • updateMessage 함수는 상위 요소로 전달한 값을 setState 함수를 통해 상태 값을 업데이트 하는 역할을 한다.
// 웹소켓 할당을 위한 변수
  final FlutterWebSocket flutterWebSocket = FlutterWebSocket();
  WebSocket? socket;

  @override
  void initState() {
    super.initState();

    createSocket(); // 웹소켓 서버 연결하기
  }

  // 서버 연결
  void createSocket() async {
    try {
      socket = await flutterWebSocket.getSocket();

      // 클라이언트 초기 설정 (서버측 클라이언트 정보 알림용 메시지 전송)
      flutterWebSocket.addMessage(socket, widget.username, "", "init");

      socket?.listen((data) {
        print("[input_text_area.dart] (createSocket) 서버로부터 받은 값 : $data");
        setState(() {
          widget.updateMessage(data);
        });
      });
    } catch (e) {
      print("[input_text_area.dart] (createSocket) 소켓 서버 접속 오류");
    }
  }

채팅앱 클라이언트 전체 소스 코드

main.dart

더보기
import 'package:flutter/material.dart';
import 'chat/chat_main.dart';

void main() async {
  runApp(
    const MaterialApp(
      home: MainPage(), //ChatMainPage(),
    ),
  );
}

class MainPage extends StatelessWidget {
  const MainPage({super.key});

  @override
  Widget build(BuildContext context) {
    // 텍스트필드 컨트롤러
    TextEditingController textEditingController = TextEditingController();

    return Scaffold(
      body: Center(
        child: Container(
          width: 250,
          height: 200,
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(20),
            border: Border.all(
              color: Colors.grey,
              width: 2,
            ),
          ),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              // 유저 이름 입력
              SizedBox(
                width: 200,
                child: TextField(
                  controller: textEditingController,
                  decoration: const InputDecoration(
                    label: Center(child: Text("사용자 이름 입력")),
                  ),
                  textAlign: TextAlign.center,
                ),
              ),
              ElevatedButton(
                onPressed: () {
                  String txtValue = textEditingController.text;
                  txtValue = txtValue.trim();
                  if (txtValue != "") {
                    Navigator.push(context,
                        MaterialPageRoute(builder: (context) {
                      return ChatMainPage(
                        username: txtValue,
                      );
                    }));
                  }
                },
                child: const Text("확인"),
              )
            ],
          ),
        ),
      ),
    );
  }
}

socket.dart

더보기
import 'dart:convert';
import 'dart:io';

class FlutterWebSocket {
  List messageList = [];
  String SERVER = "ws://192.168.10.103:4001";

  // 웹 소켓 서버 연결
  Future<WebSocket> getSocket() async {
    WebSocket socket = await WebSocket.connect(SERVER);
    return socket;
  }

  // 소켓 서버에 데이터 송신
  addMessage(socket, username, message, type) {
    Map<String, dynamic> data = {
      'username': username,
      'message': message,
      'type': type,
      // [type]
      //    - init    :   클라이언트 접속 정보 초기화
      //    - all     :   모든 클라이언트에게 메시지 전송
      //    - whisper|username :   특정 클라이언트에게 메시지 전송
    };
    print("[socket.dart] 메시지 전송 : $username : $message");
    socket?.add(jsonEncode(data));
  }
}

chat_main.dart

더보기
import 'package:flutter/material.dart';

import 'chat_area.dart';
import 'input_text_area.dart';

class ChatMainPage extends StatefulWidget {
  final String username;
  const ChatMainPage({super.key, required this.username});

  @override
  State<ChatMainPage> createState() => _ChatMainPageState();
}

class _ChatMainPageState extends State<ChatMainPage> {
  // 메시지 내용을 저장하는 변수
  List messageList = [];

  // 메시지 내용을 setState 함수를 통해 상태를 업데이트하는 함수
  void setStateMessage(data) {
    print("[chat_main.dart] (setStateMessage) 업데이트 할 값 : $data");
    setState(() => messageList.add(data));
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          children: <Widget>[
            // 메시지 내용 표시 영역
            ChatArea(
              messageList: messageList,
            ),
            // 메시지 입력 영역
            InputTextArea(
              username: widget.username,
              messageList: messageList,
              updateMessage: setStateMessage,
            )
          ],
        ),
      ),
    );
  }
}

chat_area.dart

더보기
import 'dart:convert';
import 'package:flutter/material.dart';

class ChatArea extends StatefulWidget {
  final List messageList;
  const ChatArea({super.key, required this.messageList});

  @override
  State<ChatArea> createState() => _ChatAreaState();
}

class _ChatAreaState extends State<ChatArea> {
  ScrollController scrollController = ScrollController();

  @override
  Widget build(BuildContext context) {
    // 리스트뷰에 메시지가 추가되면 스크롤을 가장 아래로 이동
    WidgetsBinding.instance.addPostFrameCallback((_) {
      scrollController.animateTo(scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 500), curve: Curves.ease);
    });

    return Expanded(
      child: ListView.builder(
        controller: scrollController,
        itemCount: widget.messageList.length,
        itemBuilder: (BuildContext context, int index) {
          // 추가된 메시지 내용
          print(
              "[chat_area.dart] (build) 추가된 메시지 내용 : ${widget.messageList[index]}");

          // JSON 문자열을 맵으로 변환
          Map<String, dynamic> data = jsonDecode(widget.messageList[index]);

          return Stack(
            children: [
              Text("${data['username']}"),
              Card(
                margin: const EdgeInsets.fromLTRB(0, 20, 0, 10),
                child: Padding(
                  padding: const EdgeInsets.fromLTRB(10, 5, 10, 5),
                  child: Text(data['message']),
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

input_text_area.dart

더보기
import 'dart:io';
import 'package:flutter/material.dart';
import '../socket.dart';

class InputTextArea extends StatefulWidget {
  final String username;
  final List messageList;
  final Function updateMessage;
  const InputTextArea(
      {super.key,
      required this.username,
      required this.messageList,
      required this.updateMessage});

  @override
  State<InputTextArea> createState() => _InputTextAreaState();
}

class _InputTextAreaState extends State<InputTextArea> {
  final TextEditingController _controller = TextEditingController();

  // 웹소켓 할당을 위한 변수
  final FlutterWebSocket flutterWebSocket = FlutterWebSocket();
  WebSocket? socket;

  @override
  void initState() {
    super.initState();

    createSocket(); // 웹소켓 서버 연결하기
  }

  // 서버 연결
  void createSocket() async {
    try {
      socket = await flutterWebSocket.getSocket();

      // 클라이언트 초기 설정 (서버측 클라이언트 정보 알림용 메시지 전송)
      flutterWebSocket.addMessage(socket, widget.username, "", "init");

      socket?.listen((data) {
        print("[input_text_area.dart] (createSocket) 서버로부터 받은 값 : $data");
        setState(() {
          widget.updateMessage(data);
        });
      });
    } catch (e) {
      print("[input_text_area.dart] (createSocket) 소켓 서버 접속 오류");
    }
  }

  // 메시지 보내기
  void sendMessage() {
    if (_controller.text.trim().isNotEmpty) {
      String message = _controller.text; // 메시지 내용
      String messageType = ""; // 메시지 타입

      // 귓속말 명령어 확인
      //    예) /w 사용자 내용
      //        - /w : 귓속말 명령어
      //        - 사용자 : 귓속말 보낼 사용자
      //        - 내용 : 귓속말 내용
      if (_controller.text.split(" ")[0] == "/w") {
        messageType = "whisper|${_controller.text.split(" ")[1]}";
        String excludeString =
            "${_controller.text.split(" ")[0]} ${_controller.text.split(" ")[1]}";

        message = "(귓속말)${_controller.text.replaceFirst(excludeString, "")}";
      }
      // 귓속말 명령어가 없으면 모두에게 메시지 보내기
      else {
        messageType = "all";
      }

      // 웹소켓 서버에 메시지 내용 전송
      flutterWebSocket.addMessage(
          socket, widget.username, message, messageType);

      _controller.clear();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        children: <Widget>[
          // 메시지 입력란
          Expanded(
            child: TextField(
              controller: _controller,
              decoration:
                  const InputDecoration(labelText: 'Enter your message'),
            ),
          ),
          // 전송버튼
          IconButton(
            icon: const Icon(Icons.send),
            onPressed: () => sendMessage(), // 메시지 보내기
          ),
        ],
      ),
    );
  }
}

 

반응형