Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Android

Lập trình Client-Server Android với Socket TCP

Bài viết hướng dẫn xây dựng mô hình **Client-Server** trên Android sử dụng **Socket TCP** (java.net.Socket / ServerSocket). Hai ứng dụng riêng biệt: Server lắng nghe, Client kết nối. Phù hợp chat LAN, điều khiển thiết bị, sync dữ liệu offline.

Yêu cầu chung

  • 2 thiết bị Android (hoặc 1 thật + 1 emulator) cùng mạng WiFi LAN.
  • Android 6.0+ (API 23+).
  • Quyền: INTERNET, ACCESS_NETWORK_STATE, Server thêm ACCESS_WIFI_STATE.

📦 SERVER APP

build.gradle.kts (Module: server)

plugins { id("com.android.application") id("org.jetbrains.kotlin.android") }
android {
    namespace = "com.example.socketserver"
    compileSdk = 34
    defaultConfig {
        applicationId = "com.example.socketserver"
        minSdk = 23
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"
    }
}
dependencies {
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
}

Manifest Server

<manifest xmlns:android="https://schemas.android.com/apk/res/android"
    package="com.example.socketserver">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <application android:allowBackup="true" android:label="@string/app_name" android:theme="@style/Theme.SocketServer">
        <activity android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Layout Server (res/layout/activity_main.xml)

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="https://schemas.android.com/apk/res/android"
    xmlns:app="https://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp">

    <TextView android:id="@+id/tvServerInfo" android:layout_width="0dp" android:layout_height="wrap_content"
        android:text="Server IP: ...\nPort: 8080" android:textSize="16sp"
        app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <TextView android:id="@+id/tvStatus" android:layout_width="0dp" android:layout_height="wrap_content"
        android:text="Trạng thái: Chờ kết nối..." android:textSize="16sp" android:textStyle="bold"
        app:layout_constraintTop_toBottomOf="@id/tvServerInfo" android:layout_marginTop="16dp"
        app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvMessages"
        android:layout_width="0dp" android:layout_height="0dp"
        app:layout_constraintTop_toBottomOf="@id/tvStatus"
        app:layout_constraintBottom_toTopOf="@id/inputLayout"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginTop="16dp" />

    <LinearLayout android:id="@+id/inputLayout" android:layout_width="0dp" android:layout_height="wrap_content"
        android:orientation="horizontal" android:layout_marginTop="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent">
        <EditText android:id="@+id/etMessage" android:layout_width="0dp" android:layout_height="wrap_content"
            android:layout_weight="1" android:hint="Tin nhắn..." android:inputType="text" />
        <Button android:id="@+id/btnSend" android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:layout_marginStart="8dp" android:text="Gửi" />
    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

Message Model & Adapter (Kotlin)

data class ChatMessage(
    val text: String,
    val isSent: Boolean, // true = Server gửi, false = Client gửi
    val timestamp: Long = System.currentTimeMillis()
)

class MessageAdapter : ListAdapter(DiffCallback()) {
    inner class VH(view: View) : RecyclerView.ViewHolder(view) {
        val tvText: TextView = view.findViewById(R.id.tvMsgText)
        val tvTime: TextView = view.findViewById(R.id.tvMsgTime)
        val container: LinearLayout = view.findViewById(R.id.msgContainer)
    }
    override fun onCreateViewHolder(p: ViewGroup, vt: Int): VH {
        val view = LayoutInflater.from(p.context).inflate(R.layout.item_message, p, false)
        return VH(view)
    }
    override fun onBindViewHolder(h: VH, pos: Int) {
        val msg = getItem(pos)
        h.tvText.text = msg.text
        h.tvTime.text = android.text.format.DateFormat.format("HH:mm", msg.timestamp)
        // Căn trái/phải, màu nền khác nhau
        val params = h.container.layoutParams as LinearLayout.LayoutParams
        params.gravity = if (msg.isSent) Gravity.END else Gravity.START
        h.container.layoutParams = params
        h.container.setBackgroundResource(if (msg.isSent) R.drawable.bg_msg_sent else R.drawable.bg_msg_received)
    }
    class DiffCallback : DiffUtil.ItemCallback() {
        override fun areItemsTheSame(o: ChatMessage, n: ChatMessage) = o.timestamp == n.timestamp
        override fun areContentsTheSame(o: ChatMessage, n: ChatMessage) = o == n
    }
}

Message Item Layout (res/layout/item_message.xml)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="https://schemas.android.com/apk/res/android"
    android:id="@+id/msgContainer"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="12dp"
    android:maxWidth="280dp">
    <TextView android:id="@+id/tvMsgText" android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:textSize="15sp" android:textColor="@android:color/black" />
    <TextView android:id="@+id/tvMsgTime" android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:textSize="10sp" android:textColor="#999" android:layout_marginTop="2dp" android:layout_gravity="end" />
</LinearLayout>

Drawables Bubble (res/drawable/bg_msg_sent.xml)

<shape xmlns:android="https://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="#2196F3" />
    <corners android:radius="16dp" android:bottomRightRadius="2dp" />
</shape>
<!-- bg_msg_received.xml -->
<shape xmlns:android="https://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="#E0E0E0" />
    <corners android:radius="16dp" android:bottomLeftRadius="2dp" />
</shape>

MainActivity Server (Kotlin + Coroutines)

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private val adapter = MessageAdapter()
    private var serverSocket: ServerSocket? = null
    private var clientSocket: Socket? = null
    private var out: PrintWriter? = null
    private val scope = CoroutineScope(Dispatchers.IO)
    private val uiScope = CoroutineScope(Dispatchers.Main)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.rvMessages.apply {
            layoutManager = LinearLayoutManager(this@MainActivity)
            adapter = this@MainActivity.adapter
        }

        val localIp = getLocalIpAddress() ?: "Không xác định"
        binding.tvServerInfo.text = "Server IP: $localIp\nPort: 8080"

        binding.btnSend.setOnClickListener {
            val text = binding.etMessage.text.toString().trim()
            if (text.isNotEmpty()) sendMessage(text)
        }

        startServer()
    }

    private fun getLocalIpAddress(): String? {
        try {
            val wifiManager = getSystemService(WIFI_SERVICE) as WifiManager
            val ipInt = wifiManager.connectionInfo.ipAddress
            if (ipInt == 0) return null
            return InetAddress.getByAddress(ipInt.toByteArray(ByteOrder.LITTLE_ENDIAN)).hostAddress
        } catch (e: Exception) { return null }
    }

    private fun startServer() {
        scope.launch {
            try {
                serverSocket = ServerSocket(8080)
                uiScope.launch { binding.tvStatus.text = "Đang lắng nghe port 8080..." }

                while (!Thread.currentThread().isInterrupted) {
                    clientSocket = serverSocket!!.accept()
                    uiScope.launch { binding.tvStatus.text = "Client kết nối: ${clientSocket!!.inetAddress}" }

                    out = PrintWriter(clientSocket!!.getOutputStream(), true)
                    val in = BufferedReader(InputStreamReader(clientSocket!!.getInputStream()))

                    // Loop đọc tin nhắn từ client
                    while (true) {
                        val line = in.readLine() ?: break
                        uiScope.launch {
                            adapter.submitList(adapter.currentList + ChatMessage(line, false))
                            binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
                        }
                    }
                    // Client ngắt kết nối
                    uiScope.launch { binding.tvStatus.text = "Client ngắt kết nối. Đang chờ mới..." }
                    closeClientResources()
                }
            } catch (e: IOException) { e.printStackTrace() }
        }
    }

    private fun sendMessage(text: String) {
        out?.println(text)
        uiScope.launch {
            adapter.submitList(adapter.currentList + ChatMessage(text, true))
            binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
            binding.etMessage.setText("")
        }
    }

    private fun closeClientResources() {
        try { out?.close(); clientSocket?.close() } catch (e: IOException) {}
        out = null; clientSocket = null
    }

    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()
        try { serverSocket?.close() } catch (e: IOException) {}
    }
}

📱 CLIENT APP

build.gradle.kts (Module: client) - Tương tự, đổi applicationId

namespace = "com.example.socketclient"
defaultConfig { applicationId = "com.example.socketclient" ... }

Manifest Client

<manifest ... package="com.example.socketclient">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <application ...>
        <activity android:name=".MainActivity" android:exported="true">...</activity>
    </application>
</manifest>

Layout Client (res/layout/activity_main.xml)

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout ... android:padding="16dp">
    <EditText android:id="@+id/etServerIp" android:layout_width="0dp" android:layout_height="wrap_content"
        android:hint="IP Server (vd 192.168.1.15)" android:inputType="text"
        app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />
    <EditText android:id="@+id/etServerPort" android:layout_width="0dp" android:layout_height="wrap_content"
        android:hint="Port (8080)" android:inputType="number"
        app:layout_constraintTop_toBottomOf="@id/etServerIp" app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" android:layout_marginTop="8dp" />
    <Button android:id="@+id/btnConnect" android:layout_width="0dp" android:layout_height="wrap_content"
        android:text="KẾT NỐI"
        app:layout_constraintTop_toBottomOf="@id/etServerPort" app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" android:layout_marginTop="16dp" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvMessages" android:layout_width="0dp" android:layout_height="0dp"
        app:layout_constraintTop_toBottomOf="@id/btnConnect"
        app:layout_constraintBottom_toTopOf="@id/inputLayout"
        app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginTop="16dp" />

    <LinearLayout android:id="@+id/inputLayout" android:layout_width="0dp" android:layout_height="wrap_content"
        android:orientation="horizontal" android:layout_marginTop="8dp" android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent">
        <EditText android:id="@+id/etMessage" android:layout_width="0dp" android:layout_height="wrap_content"
            android:layout_weight="1" android:hint="Tin nhắn..." />
        <Button android:id="@+id/btnSend" android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:layout_marginStart="8dp" android:text="Gửi" />
    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity Client (Kotlin Coroutines)

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private val adapter = MessageAdapter() // Cùng class MessageAdapter
    private var socket: Socket? = null
    private var out: PrintWriter? = null
    private val scope = CoroutineScope(Dispatchers.IO)
    private val uiScope = CoroutineScope(Dispatchers.Main)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.rvMessages.apply {
            layoutManager = LinearLayoutManager(this@MainActivity)
            adapter = this@MainActivity.adapter
        }

        binding.btnConnect.setOnClickListener { connect() }
        binding.btnSend.setOnClickListener { sendMessage() }
    }

    private fun connect() {
        val ip = binding.etServerIp.text.toString().trim()
        val portStr = binding.etServerPort.text.toString().trim()
        if (ip.isEmpty() || portStr.isEmpty()) return
        val port = portStr.toIntOrNull() ?: return

        scope.launch {
            try {
                socket = Socket(ip, port)
                out = PrintWriter(socket!!.getOutputStream(), true)
                val in = BufferedReader(InputStreamReader(socket!!.getInputStream()))

                uiScope.launch {
                    binding.tvMessages.text = "Đã kết nối $ip:$port"
                    binding.btnConnect.isEnabled = false
                    binding.etServerIp.isEnabled = false
                    binding.etServerPort.isEnabled = false
                    binding.inputLayout.visibility = View.VISIBLE
                    binding.btnSend.isEnabled = true
                }

                // Loop nhận từ server
                while (true) {
                    val line = in.readLine() ?: break
                    uiScope.launch {
                        adapter.submitList(adapter.currentList + ChatMessage(line, false))
                        binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
                    }
                }
            } catch (e: IOException) {
                uiScope.launch { binding.tvMessages.text = "Lỗi kết nối: ${e.message}" }
            } finally {
                onDisconnected()
            }
        }
    }

    private fun sendMessage() {
        val text = binding.etMessage.text.toString().trim()
        if (text.isNotEmpty()) {
            out?.println(text)
            uiScope.launch {
                adapter.submitList(adapter.currentList + ChatMessage(text, true))
                binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
                binding.etMessage.setText("")
            }
        }
    }

    private fun onDisconnected() {
        uiScope.launch {
            binding.btnConnect.isEnabled = true
            binding.etServerIp.isEnabled = true
            binding.etServerPort.isEnabled = true
            binding.inputLayout.visibility = View.GONE
            binding.btnSend.isEnabled = false
            adapter.submitList(adapter.currentList + ChatMessage("-- Mất kết nối --", false))
        }
        try { socket?.close() } catch (e: IOException) {}
        socket = null; out = null
    }

    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()
        try { socket?.close() } catch (e: IOException) {}
    }
}

🚀 Chạy và Test

  1. Build & cài **Server App** lên thiết bị A. Mở app, ghi nhớ IP hiển thị (ví dụ: 192.168.1.42).
  2. Build & cài **Client App** lên thiết bị B. Nhập IP của A, Port 8080, nhấn **KẾT NỐI**.
  3. Hai bên chat realtime. Thử tắt WiFi một bên để test reconnect logic.

⚠️ Các lỗi thường gặp & Fix

LỗiNguyên nhânKhắc phục
Connection refusedServer chưa start, sai IP/Port, FirewallKiểm tra Server chạy, IP đúng, cùng subnet, tắt tường lửa test
NetworkOnMainThreadExceptionLàm network trên Main ThreadDùng Coroutine Dispatchers.IO / Thread / RxJava
SocketTimeout / Read timeoutMạng không ổn địnhSet SO_TIMEOUT, implement heartbeat
Emulator không kết nối được thiết bị thậtEmulator dùng NAT riêng (10.0.2.x)Dùng 10.0.2.2 để truy cập localhost máy host, hoặc cấu hình port forwarding
IPv6 vs IPv4ServerSocket mặc định IPv6 dual-stackClient connect bằng IPv4 address, hoặc dùng InetAddress.getByName

🔧 Nâng cao cho Production

  • Protocol: Thay plain text bằng JSON/Protobuf (kotlinx.serialization).
  • Security: SSLSocketFactory + Certificate pinning.
  • Discovery: NSD (Network Service Discovery) hoặc mDNS/Bonjour để tự tìm Server IP.
  • Reconnection: Exponential backoff retry logic.
  • Architecture: Repository + ViewModel + Flow, tách Network Module.

Kết luận

Socket TCP trên Android cho phép giao tiếp realtime, hai chiều, độ trễ thấp trên mạng cục bộ. Triển khai đúng đắn cần: Coroutine/Thread cho I/O, quản lý vòng đời Socket, xử lý reconnection, và protocol rõ ràng. Code Kotlin + Coroutines + RecyclerView ở trên là nền tảng sẵn sàng mở rộng.