Home > Blog > tech

Push Notification คืออะไร? สอนสร้างระบบ Push และ Real-time สำหรับ Mobile App 2026

push notifications realtime mobile guide
Push Notifications and Real-time Mobile Guide 2026
2026-04-10 | tech | 3500 words

Push Notification เป็นช่องทางสำคัญที่สุดในการดึงผู้ใช้กลับมาใช้แอปอีกครั้ง ในปี 2026 แอปที่ส่ง Push Notification อย่างชาญฉลาดมี Retention Rate สูงกว่าแอปที่ไม่ส่งถึง 3-10 เท่า แต่ถ้าส่งมากเกินไปหรือไม่ตรงเป้าหมาย ผู้ใช้จะปิด Notification หรือถอนการติดตั้งแอปทันที การออกแบบระบบ Push Notification และ Real-time ที่ดีจึงเป็นทักษะที่ Mobile Developer ต้องมี

บทความนี้จะพาคุณเข้าใจ Push Notification ตั้งแต่พื้นฐานจนถึง Implementation จริง ครอบคลุม FCM, APNs, Rich Notification, Deep Linking, Server-side Push, Real-time Features ด้วย WebSocket และ Firebase Realtime Database สำหรับผู้ที่ต้องการเข้าใจ Architecture ของ Mobile App ก่อน แนะนำอ่าน คู่มือ Mobile App Architecture ประกอบ

ประเภทของ Push Notification

Push Notification แบ่งออกเป็น 4 ประเภทหลัก แต่ละประเภทมีจุดประสงค์และวิธีจัดการที่แตกต่างกัน

1. Remote Push Notification

เป็น Notification ที่ส่งจาก Server ผ่าน Push Service (FCM / APNs) ไปยังอุปกรณ์ของผู้ใช้ ใช้สำหรับแจ้งเตือนข้อความใหม่ ข่าว โปรโมชั่น หรือ Event สำคัญ เป็นประเภทที่ใช้มากที่สุดในแอปทั่วไป

2. Local Notification

สร้างจากภายในแอปเอง ไม่ต้องผ่าน Server เหมาะสำหรับ Reminder, Alarm, ตั้งเวลาเตือน หรือแจ้งเตือนเมื่อทำ Task บางอย่างเสร็จ ทำงานได้แม้ไม่มี Internet

3. Rich Notification

Notification ที่แสดงเนื้อหามากกว่าแค่ข้อความ เช่น รูปภาพ ปุ่ม Action วิดีโอ หรือ UI แบบ Expandable ช่วยเพิ่ม Engagement ได้มากกว่า Text-only Notification

4. Silent Notification (Data-only)

Notification ที่ส่งมาแบบเงียบๆ ไม่แสดงอะไรบนหน้าจอ แต่ปลุกแอปให้ทำงานเบื้องหลัง เช่น Sync ข้อมูล อัพเดต Cache หรือเตรียมข้อมูลสำหรับการใช้งานครั้งต่อไป

ประเภทต้องการ Serverต้องการ Internetแสดง UIUse Case
Remote Pushใช่ใช่ใช่ข้อความ, ข่าว, โปรโมชั่น
Localไม่ไม่ใช่Reminder, Alarm, Timer
Richใช่ใช่ใช่ (พร้อมสื่อ)ข่าว+รูป, ปุ่ม Action
Silentใช่ใช่ไม่Data Sync, Cache Update

FCM (Firebase Cloud Messaging) — Setup

FCM เป็น Push Service ของ Google ที่ใช้ส่ง Notification ไปยังทั้ง Android และ iOS (ผ่าน APNs) รองรับทั้ง Individual, Topic-based และ Conditional messaging เป็นมาตรฐานที่ใช้มากที่สุดในปี 2026

Android Setup

// 1. เพิ่ม Firebase SDK (build.gradle.kts)
plugins {
    id("com.google.gms.google-services")
}

dependencies {
    implementation(platform("com.google.firebase:firebase-bom:33.0.0"))
    implementation("com.google.firebase:firebase-messaging-ktx")
}

// 2. สร้าง FirebaseMessagingService
class MyFirebaseMessagingService : FirebaseMessagingService() {

    // เมื่อได้รับ Token ใหม่
    override fun onNewToken(token: String) {
        super.onNewToken(token)
        Log.d("FCM", "New token: $token")
        // ส่ง Token ไป Server
        sendTokenToServer(token)
    }

    // เมื่อได้รับ Notification
    override fun onMessageReceived(message: RemoteMessage) {
        super.onMessageReceived(message)

        // Data payload — จัดการเองทั้งหมด
        message.data.let { data ->
            val type = data["type"]
            val targetId = data["target_id"]
            handleDataMessage(type, targetId, data)
        }

        // Notification payload — แสดงอัตโนมัติเมื่อ App อยู่ Background
        message.notification?.let { notification ->
            showNotification(
                title = notification.title ?: "",
                body = notification.body ?: "",
                data = message.data
            )
        }
    }

    private fun showNotification(title: String, body: String, data: Map<String, String>) {
        val channelId = data["channel"] ?: "default"

        // Deep Link Intent
        val intent = Intent(this, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
            putExtra("type", data["type"])
            putExtra("target_id", data["target_id"])
        }

        val pendingIntent = PendingIntent.getActivity(
            this, 0, intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        val notification = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(title)
            .setContentText(body)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setContentIntent(pendingIntent)
            .setAutoCancel(true)
            .build()

        val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
        manager.notify(System.currentTimeMillis().toInt(), notification)
    }
}

// 3. AndroidManifest.xml
// <service android:name=".MyFirebaseMessagingService"
//     android:exported="false">
//     <intent-filter>
//         <action android:name="com.google.firebase.MESSAGING_EVENT" />
//     </intent-filter>
// </service>

iOS Setup (Swift)

// AppDelegate.swift
import Firebase
import UserNotifications

@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        FirebaseApp.configure()
        Messaging.messaging().delegate = self
        UNUserNotificationCenter.current().delegate = self

        // ขอ Permission
        UNUserNotificationCenter.current().requestAuthorization(
            options: [.alert, .badge, .sound]
        ) { granted, error in
            if granted {
                DispatchQueue.main.async {
                    application.registerForRemoteNotifications()
                }
            }
        }
        return true
    }

    // ได้รับ Device Token จาก APNs
    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Messaging.messaging().apnsToken = deviceToken
    }

    // จัดการ Notification เมื่อ App อยู่ Foreground
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler:
                                @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.banner, .sound, .badge])
    }

    // จัดการเมื่อผู้ใช้กด Notification
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo
        handleDeepLink(from: userInfo)
        completionHandler()
    }
}

extension AppDelegate: MessagingDelegate {
    func messaging(_ messaging: Messaging,
                   didReceiveRegistrationToken fcmToken: String?) {
        guard let token = fcmToken else { return }
        print("FCM Token: \(token)")
        sendTokenToServer(token)
    }
}

Flutter Setup

// Flutter — firebase_messaging
import 'package:firebase_messaging/firebase_messaging.dart';

// Background handler ต้องเป็น top-level function
@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  print('Background message: ${message.messageId}');
  // จัดการ Data Message ที่นี่
}

class PushNotificationService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  Future<void> initialize() async {
    // ขอ Permission (iOS)
    NotificationSettings settings = await _messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
      provisional: false,
    );

    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      print('Notification permission granted');
    }

    // รับ Token
    String? token = await _messaging.getToken();
    print('FCM Token: $token');
    if (token != null) {
      await _sendTokenToServer(token);
    }

    // Token Refresh
    _messaging.onTokenRefresh.listen(_sendTokenToServer);

    // Foreground Messages
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      print('Foreground message: ${message.notification?.title}');
      _showLocalNotification(message);
    });

    // เมื่อกด Notification ตอน App อยู่ Background
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      _handleDeepLink(message.data);
    });

    // เมื่อเปิด App จาก Notification (terminated state)
    RemoteMessage? initialMessage = await _messaging.getInitialMessage();
    if (initialMessage != null) {
      _handleDeepLink(initialMessage.data);
    }

    // Background handler
    FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
  }

  Future<void> _sendTokenToServer(String token) async {
    // API call ส่ง token ไป server
  }
}

Notification Channels (Android) และ Categories (iOS)

ตั้งแต่ Android 8.0 (API 26) ทุกแอปต้องสร้าง Notification Channel เพื่อให้ผู้ใช้ควบคุมการแจ้งเตือนได้ละเอียดขึ้น ส่วน iOS ใช้ Categories สำหรับจัดกลุ่ม Action

// Android — Notification Channels
class NotificationChannelManager(private val context: Context) {

    fun createChannels() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val manager = context.getSystemService(NotificationManager::class.java)

            // ช่อง: ข้อความ Chat (สำคัญสูง)
            val chatChannel = NotificationChannel(
                "chat_messages",
                "ข้อความแชท",
                NotificationManager.IMPORTANCE_HIGH
            ).apply {
                description = "แจ้งเตือนเมื่อมีข้อความใหม่"
                enableLights(true)
                lightColor = Color.BLUE
                enableVibration(true)
                vibrationPattern = longArrayOf(0, 250, 250, 250)
                setSound(
                    RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
                    AudioAttributes.Builder()
                        .setUsage(AudioAttributes.USAGE_NOTIFICATION)
                        .build()
                )
            }

            // ช่อง: อัพเดตทั่วไป (สำคัญปกติ)
            val updateChannel = NotificationChannel(
                "general_updates",
                "อัพเดตทั่วไป",
                NotificationManager.IMPORTANCE_DEFAULT
            ).apply {
                description = "ข่าวสาร อัพเดต ฟีเจอร์ใหม่"
            }

            // ช่อง: โปรโมชั่น (สำคัญต่ำ)
            val promoChannel = NotificationChannel(
                "promotions",
                "โปรโมชั่นและข้อเสนอ",
                NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = "โปรโมชั่น ส่วนลด ข้อเสนอพิเศษ"
            }

            manager.createNotificationChannels(
                listOf(chatChannel, updateChannel, promoChannel)
            )
        }
    }
}
// iOS — Notification Categories + Actions
func registerNotificationCategories() {
    // Action: ตอบกลับ
    let replyAction = UNTextInputNotificationAction(
        identifier: "REPLY_ACTION",
        title: "ตอบกลับ",
        options: [],
        textInputButtonTitle: "ส่ง",
        textInputPlaceholder: "พิมพ์ข้อความ..."
    )

    // Action: Like
    let likeAction = UNNotificationAction(
        identifier: "LIKE_ACTION",
        title: "ถูกใจ",
        options: []
    )

    // Action: Mark as Read
    let readAction = UNNotificationAction(
        identifier: "MARK_READ_ACTION",
        title: "อ่านแล้ว",
        options: .destructive
    )

    // Category: Chat Message
    let chatCategory = UNNotificationCategory(
        identifier: "CHAT_MESSAGE",
        actions: [replyAction, likeAction, readAction],
        intentIdentifiers: [],
        options: .customDismissAction
    )

    UNUserNotificationCenter.current().setNotificationCategories([chatCategory])
}

Notification Payload Structure

การออกแบบ Payload ที่ดีเป็นกุญแจสำคัญ FCM รองรับ 2 ประเภท Payload คือ notification (แสดงอัตโนมัติ) และ data (จัดการเอง) แนะนำให้ใช้ data-only เสมอเพื่อควบคุมการแสดงผลได้เต็มที่

// Notification Payload — แสดงอัตโนมัติ (ควบคุมน้อย)
{
  "message": {
    "token": "device-fcm-token",
    "notification": {
      "title": "ข้อความใหม่",
      "body": "สวัสดีครับ มีอะไรอัพเดตบ้าง?"
    }
  }
}

// Data-only Payload — ควบคุมเต็มที่ (แนะนำ)
{
  "message": {
    "token": "device-fcm-token",
    "data": {
      "type": "chat_message",
      "title": "ข้อความจาก John",
      "body": "สวัสดีครับ มีอะไรอัพเดตบ้าง?",
      "sender_id": "user-123",
      "chat_id": "chat-456",
      "sender_avatar": "https://example.com/avatar.jpg",
      "timestamp": "2026-04-10T14:30:00Z",
      "deep_link": "myapp://chat/chat-456"
    },
    "android": {
      "priority": "high"
    },
    "apns": {
      "payload": {
        "aps": {
          "content-available": 1
        }
      },
      "headers": {
        "apns-priority": "10"
      }
    }
  }
}
Data-only vs Notification Payload: ใช้ Data-only Payload เสมอเมื่อต้องการควบคุมการแสดง Notification เช่น แสดงรูปภาพ Deep Link หรือ Custom UI เพราะ Notification Payload จะถูก System จัดการอัตโนมัติซึ่งควบคุมไม่ได้ สำหรับแนวทางออกแบบ API เพิ่มเติม ดูที่ คู่มือ API Design

Server-side Push Implementation (Node.js)

// server/push-notification.ts
import admin from 'firebase-admin';

// Initialize Firebase Admin
admin.initializeApp({
  credential: admin.credential.cert('./service-account-key.json'),
});

interface PushPayload {
  type: string;
  title: string;
  body: string;
  data?: Record<string, string>;
  imageUrl?: string;
}

// ส่งไปยังอุปกรณ์เดียว
async function sendToDevice(token: string, payload: PushPayload) {
  const message: admin.messaging.Message = {
    token,
    data: {
      type: payload.type,
      title: payload.title,
      body: payload.body,
      image_url: payload.imageUrl || '',
      ...payload.data,
    },
    android: {
      priority: 'high',
      ttl: 86400 * 1000,  // 24 ชั่วโมง
    },
    apns: {
      payload: {
        aps: {
          'content-available': 1,
          sound: 'default',
          badge: 1,
        },
      },
      headers: {
        'apns-priority': '10',
        'apns-expiration': String(Math.floor(Date.now() / 1000) + 86400),
      },
    },
  };

  try {
    const response = await admin.messaging().send(message);
    console.log('Push sent:', response);
    return { success: true, messageId: response };
  } catch (error: any) {
    console.error('Push failed:', error);

    // Token หมดอายุ — ลบออกจาก DB
    if (error.code === 'messaging/registration-token-not-registered') {
      await removeInvalidToken(token);
    }

    return { success: false, error: error.message };
  }
}

// ส่งไปยังหลายอุปกรณ์ (Batch)
async function sendToMultipleDevices(tokens: string[], payload: PushPayload) {
  const message: admin.messaging.MulticastMessage = {
    tokens,
    data: {
      type: payload.type,
      title: payload.title,
      body: payload.body,
      ...payload.data,
    },
    android: { priority: 'high' },
    apns: {
      payload: { aps: { 'content-available': 1, sound: 'default' } },
    },
  };

  const response = await admin.messaging().sendEachForMulticast(message);

  // จัดการ Token ที่ Fail
  response.responses.forEach((res, index) => {
    if (!res.success && res.error?.code === 'messaging/registration-token-not-registered') {
      removeInvalidToken(tokens[index]);
    }
  });

  return {
    successCount: response.successCount,
    failureCount: response.failureCount,
  };
}

Topic-based Notifications

Topic-based Notification ช่วยให้คุณส่ง Push ไปยังกลุ่มผู้ใช้ที่สนใจเรื่องเดียวกัน โดยไม่ต้องเก็บ Token ของทุกคน

// Client — Subscribe to Topic (Flutter)
await FirebaseMessaging.instance.subscribeToTopic('news_tech');
await FirebaseMessaging.instance.subscribeToTopic('deals_bangkok');

// Unsubscribe
await FirebaseMessaging.instance.unsubscribeFromTopic('deals_bangkok');

// Server — ส่งไป Topic
async function sendToTopic(topic: string, payload: PushPayload) {
  const message: admin.messaging.Message = {
    topic,
    data: {
      type: payload.type,
      title: payload.title,
      body: payload.body,
      ...payload.data,
    },
  };

  return admin.messaging().send(message);
}

// ส่งแบบ Condition (AND, OR)
async function sendToCondition(payload: PushPayload) {
  const message: admin.messaging.Message = {
    // ผู้ใช้ที่ subscribe ทั้ง 'news' AND 'tech'
    condition: "'news' in topics && 'tech' in topics",
    data: {
      type: payload.type,
      title: payload.title,
      body: payload.body,
    },
  };

  return admin.messaging().send(message);
}

Token Management

Token Management เป็นหัวใจของระบบ Push Notification เพราะ FCM Token สามารถเปลี่ยนได้ตลอดเวลา (อัพเดตแอป ล้างข้อมูล เปลี่ยนอุปกรณ์) ถ้าไม่จัดการดี จะส่ง Push ไม่ถึงหรือส่งไปอุปกรณ์ที่ไม่มีแอปแล้ว

// Server — Token Management Service
interface DeviceToken {
  userId: string;
  token: string;
  platform: 'android' | 'ios';
  appVersion: string;
  lastActive: Date;
  createdAt: Date;
}

class TokenService {
  // บันทึก Token ใหม่ หรืออัพเดต Token เดิม
  async registerToken(userId: string, token: string, platform: string) {
    // ลบ Token เดิมของ Device นี้ (ถ้ามี)
    await db.deviceTokens.deleteMany({ token });

    // บันทึก Token ใหม่
    await db.deviceTokens.upsert({
      where: { userId_platform: { userId, platform } },
      create: { userId, token, platform, lastActive: new Date() },
      update: { token, lastActive: new Date() },
    });
  }

  // ลบ Token ที่หมดอายุ
  async removeInvalidToken(token: string) {
    await db.deviceTokens.deleteMany({ where: { token } });
  }

  // ดึง Token ทั้งหมดของ User
  async getUserTokens(userId: string): Promise<string[]> {
    const devices = await db.deviceTokens.findMany({
      where: { userId },
      select: { token: true },
    });
    return devices.map(d => d.token);
  }

  // ลบ Token ที่ไม่ Active นานเกิน 30 วัน
  async cleanupStaleTokens() {
    const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
    const result = await db.deviceTokens.deleteMany({
      where: { lastActive: { lt: thirtyDaysAgo } },
    });
    console.log(`Cleaned ${result.count} stale tokens`);
  }
}

Deep Linking จาก Notification

Deep Linking ช่วยให้ผู้ใช้กด Notification แล้วไปถึงหน้าจอที่ถูกต้องทันที ไม่ใช่ไปหน้า Home แล้วต้องนำทางเอง สำหรับเรื่อง WebSocket และ Real-time ที่เกี่ยวข้อง ดูที่ คู่มือ WebSocket

// Android — Deep Link Handler
class DeepLinkHandler(private val navController: NavController) {

    fun handleNotificationData(data: Map<String, String>) {
        val type = data["type"] ?: return
        val targetId = data["target_id"] ?: return

        when (type) {
            "chat_message" -> {
                navController.navigate("chat/$targetId")
            }
            "order_update" -> {
                navController.navigate("order/$targetId")
            }
            "new_follower" -> {
                navController.navigate("profile/$targetId")
            }
            "promo" -> {
                val url = data["promo_url"] ?: return
                navController.navigate("webview?url=$url")
            }
            else -> {
                navController.navigate("home")
            }
        }
    }
}

// Flutter — Deep Link from Notification
void _handleDeepLink(Map<String, dynamic> data) {
  final type = data['type'] as String?;
  final targetId = data['target_id'] as String?;

  switch (type) {
    case 'chat_message':
      router.push('/chat/$targetId');
      break;
    case 'order_update':
      router.push('/order/$targetId');
      break;
    case 'new_follower':
      router.push('/profile/$targetId');
      break;
    default:
      router.push('/');
  }
}

Rich Notifications

Rich Notification เพิ่ม Engagement ได้มากกว่า Text-only Notification ถึง 56% ตามสถิติปี 2026 รองรับรูปภาพ ปุ่ม Action และ Expandable Content

Android — Rich Notification

// Big Picture Style (แสดงรูปภาพ)
fun showRichNotification(title: String, body: String, imageUrl: String) {
    // โหลดรูปภาพก่อน
    val bitmap = Glide.with(context)
        .asBitmap()
        .load(imageUrl)
        .submit()
        .get()

    val notification = NotificationCompat.Builder(context, "general")
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle(title)
        .setContentText(body)
        .setLargeIcon(bitmap)
        .setStyle(
            NotificationCompat.BigPictureStyle()
                .bigPicture(bitmap)
                .bigLargeIcon(null as Bitmap?)
        )
        // Action Buttons
        .addAction(R.drawable.ic_reply, "ตอบกลับ", replyPendingIntent)
        .addAction(R.drawable.ic_like, "ถูกใจ", likePendingIntent)
        .setAutoCancel(true)
        .build()

    NotificationManagerCompat.from(context).notify(notificationId, notification)
}

// Messaging Style (สำหรับ Chat)
fun showChatNotification(messages: List<ChatMessage>) {
    val person = Person.Builder()
        .setName(messages.first().senderName)
        .setIcon(IconCompat.createWithBitmap(avatarBitmap))
        .build()

    val style = NotificationCompat.MessagingStyle(person)
        .setConversationTitle("กลุ่ม Developer Thailand")

    messages.forEach { msg ->
        style.addMessage(msg.text, msg.timestamp, person)
    }

    val notification = NotificationCompat.Builder(context, "chat_messages")
        .setSmallIcon(R.drawable.ic_chat)
        .setStyle(style)
        .setCategory(NotificationCompat.CATEGORY_MESSAGE)
        .build()

    NotificationManagerCompat.from(context).notify(chatId, notification)
}

iOS — Notification Service Extension (Rich Media)

// NotificationService.swift (Notification Service Extension)
class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler:
                             @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

        guard let content = bestAttemptContent,
              let imageUrlString = content.userInfo["image_url"] as? String,
              let imageUrl = URL(string: imageUrlString) else {
            contentHandler(request.content)
            return
        }

        // ดาวน์โหลดรูปภาพ
        downloadImage(from: imageUrl) { localUrl in
            if let localUrl = localUrl {
                let attachment = try? UNNotificationAttachment(
                    identifier: "image",
                    url: localUrl,
                    options: nil
                )
                if let attachment = attachment {
                    content.attachments = [attachment]
                }
            }
            contentHandler(content)
        }
    }

    private func downloadImage(from url: URL, completion: @escaping (URL?) -> Void) {
        URLSession.shared.downloadTask(with: url) { localUrl, _, _ in
            guard let localUrl = localUrl else {
                completion(nil)
                return
            }
            let tmpUrl = FileManager.default.temporaryDirectory
                .appendingPathComponent(UUID().uuidString + ".jpg")
            try? FileManager.default.moveItem(at: localUrl, to: tmpUrl)
            completion(tmpUrl)
        }.resume()
    }
}

Silent Notifications — Background Data Sync

// Android — Data-only Message (Background)
override fun onMessageReceived(message: RemoteMessage) {
    if (message.data["type"] == "data_sync") {
        // Sync ข้อมูลเบื้องหลัง ไม่แสดง Notification
        CoroutineScope(Dispatchers.IO).launch {
            val syncType = message.data["sync_type"]
            when (syncType) {
                "products" -> productRepository.syncFromServer()
                "settings" -> settingsRepository.refreshSettings()
                "messages" -> messageRepository.fetchNewMessages()
            }
        }
    }
}

// iOS — Silent Push (content-available)
// Server Payload:
// {
//   "aps": { "content-available": 1 },
//   "sync_type": "products"
// }

func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                 fetchCompletionHandler completionHandler:
                 @escaping (UIBackgroundFetchResult) -> Void) {
    let syncType = userInfo["sync_type"] as? String ?? ""

    Task {
        do {
            switch syncType {
            case "products":
                try await ProductRepository.shared.syncFromServer()
            case "settings":
                try await SettingsRepository.shared.refresh()
            default:
                break
            }
            completionHandler(.newData)
        } catch {
            completionHandler(.failed)
        }
    }
}

Notification Analytics

// Notification Analytics Tracker
class NotificationAnalytics {
  // Track เมื่อส่ง Push
  async trackSent(notificationId: string, userId: string, type: string) {
    await db.notificationEvents.create({
      data: {
        notificationId,
        userId,
        type,
        event: 'sent',
        timestamp: new Date(),
      },
    });
  }

  // Track เมื่อ Push ถูก Delivered
  async trackDelivered(notificationId: string) {
    await db.notificationEvents.create({
      data: { notificationId, event: 'delivered', timestamp: new Date() },
    });
  }

  // Track เมื่อผู้ใช้กด Notification (Open)
  async trackOpened(notificationId: string) {
    await db.notificationEvents.create({
      data: { notificationId, event: 'opened', timestamp: new Date() },
    });
  }

  // คำนวณ Metrics
  async getMetrics(startDate: Date, endDate: Date) {
    const sent = await db.notificationEvents.count({
      where: { event: 'sent', timestamp: { gte: startDate, lte: endDate } },
    });
    const delivered = await db.notificationEvents.count({
      where: { event: 'delivered', timestamp: { gte: startDate, lte: endDate } },
    });
    const opened = await db.notificationEvents.count({
      where: { event: 'opened', timestamp: { gte: startDate, lte: endDate } },
    });

    return {
      sent,
      delivered,
      opened,
      deliveryRate: delivered / sent * 100,    // อัตราส่งสำเร็จ
      openRate: opened / delivered * 100,      // อัตราเปิดอ่าน
      ctr: opened / sent * 100,               // Click-Through Rate
    };
  }
}

หลีกเลี่ยง Notification Fatigue

Notification Fatigue เกิดขึ้นเมื่อผู้ใช้ได้รับ Push Notification มากเกินไปจนเริ่มไม่สนใจ ปิด Notification หรือถอนการติดตั้งแอป กฎที่ต้องจำคือ ส่งน้อยแต่ตรงเป้า ดีกว่าส่งมากแต่ไม่ตรงใจ

// Frequency Capping Service
class NotificationThrottler {
  private readonly MAX_PER_DAY = 5;
  private readonly MAX_PER_HOUR = 2;
  private readonly QUIET_HOURS = { start: 21, end: 7 };

  async canSend(userId: string, type: string): Promise<boolean> {
    // Chat messages ไม่จำกัด
    if (type === 'chat_message') return true;

    // ตรวจสอบ Quiet Hours (ใช้ Timezone ของ User)
    const userTz = await this.getUserTimezone(userId);
    const userHour = this.getCurrentHourInTimezone(userTz);
    if (userHour >= this.QUIET_HOURS.start || userHour < this.QUIET_HOURS.end) {
      return false;
    }

    // ตรวจสอบ Rate Limit
    const sentToday = await this.getCountSince(userId, 'day');
    if (sentToday >= this.MAX_PER_DAY) return false;

    const sentThisHour = await this.getCountSince(userId, 'hour');
    if (sentThisHour >= this.MAX_PER_HOUR) return false;

    return true;
  }
}

Real-time Features สำหรับ Mobile App

นอกจาก Push Notification แล้ว Real-time Features เป็นสิ่งที่ผู้ใช้คาดหวังในแอปสมัยใหม่ ไม่ว่าจะเป็น Chat, Live Updates, Real-time Collaboration หรือ Live Tracking สำหรับการเข้าใจ Event-driven Architecture ที่เป็นพื้นฐาน ดูที่ คู่มือ Event-driven Architecture

WebSocket สำหรับ Chat

// Flutter — WebSocket Chat Client
class ChatWebSocket {
  late WebSocketChannel _channel;
  final StreamController<ChatMessage> _messageController =
      StreamController<ChatMessage>.broadcast();

  Stream<ChatMessage> get messages => _messageController.stream;

  void connect(String userId, String authToken) {
    _channel = WebSocketChannel.connect(
      Uri.parse('wss://api.example.com/ws/chat?token=$authToken'),
    );

    _channel.stream.listen(
      (data) {
        final json = jsonDecode(data);
        switch (json['type']) {
          case 'message':
            final msg = ChatMessage.fromJson(json['payload']);
            _messageController.add(msg);
            break;
          case 'typing':
            _handleTypingIndicator(json['payload']);
            break;
          case 'read_receipt':
            _handleReadReceipt(json['payload']);
            break;
        }
      },
      onError: (error) {
        print('WebSocket error: $error');
        _reconnect(userId, authToken);
      },
      onDone: () {
        print('WebSocket closed');
        _reconnect(userId, authToken);
      },
    );
  }

  void sendMessage(String chatId, String text) {
    _channel.sink.add(jsonEncode({
      'type': 'message',
      'payload': {
        'chat_id': chatId,
        'text': text,
        'timestamp': DateTime.now().toIso8601String(),
      },
    }));
  }

  void sendTypingIndicator(String chatId) {
    _channel.sink.add(jsonEncode({
      'type': 'typing',
      'payload': { 'chat_id': chatId },
    }));
  }

  void _reconnect(String userId, String authToken) {
    Future.delayed(Duration(seconds: 3), () {
      connect(userId, authToken);
    });
  }

  void dispose() {
    _channel.sink.close();
    _messageController.close();
  }
}

Server-Sent Events (SSE) สำหรับ Live Data

// React Native — SSE for Live Price Updates
import EventSource from 'react-native-sse';

function useLivePrices() {
  const [prices, setPrices] = useState<PriceData[]>([]);

  useEffect(() => {
    const es = new EventSource('https://api.example.com/sse/prices', {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });

    es.addEventListener('price_update', (event) => {
      const data = JSON.parse(event.data);
      setPrices((prev) =>
        prev.map((p) =>
          p.symbol === data.symbol ? { ...p, price: data.price } : p
        )
      );
    });

    es.addEventListener('market_status', (event) => {
      const data = JSON.parse(event.data);
      // อัพเดตสถานะตลาด
    });

    es.addEventListener('error', (error) => {
      console.error('SSE error:', error);
    });

    return () => es.close();
  }, []);

  return prices;
}

// Node.js Server — SSE Endpoint
app.get('/sse/prices', authenticate, (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const interval = setInterval(() => {
    const priceData = getCurrentPrices();
    res.write(`event: price_update\n`);
    res.write(`data: ${JSON.stringify(priceData)}\n\n`);
  }, 1000);

  req.on('close', () => {
    clearInterval(interval);
    res.end();
  });
});

Firebase Realtime Database

// Flutter — Firebase Realtime Database
class LiveOrderTracker {
  final DatabaseReference _ordersRef =
      FirebaseDatabase.instance.ref('orders');

  // ติดตาม Order Status แบบ Real-time
  Stream<OrderStatus> watchOrderStatus(String orderId) {
    return _ordersRef
        .child(orderId)
        .child('status')
        .onValue
        .map((event) {
          final data = event.snapshot.value as Map?;
          return OrderStatus.fromJson(data ?? {});
        });
  }

  // อัพเดต Location ของ Driver (Delivery)
  Future<void> updateDriverLocation(
    String orderId,
    double lat,
    double lng,
  ) async {
    await _ordersRef.child(orderId).child('driver_location').set({
      'lat': lat,
      'lng': lng,
      'updated_at': ServerValue.timestamp,
    });
  }

  // Presence System (Online/Offline)
  void setupPresence(String userId) {
    final userStatusRef =
        FirebaseDatabase.instance.ref('status/$userId');
    final connectedRef =
        FirebaseDatabase.instance.ref('.info/connected');

    connectedRef.onValue.listen((event) {
      if (event.snapshot.value == true) {
        userStatusRef.onDisconnect().set({
          'state': 'offline',
          'last_seen': ServerValue.timestamp,
        });

        userStatusRef.set({
          'state': 'online',
          'last_seen': ServerValue.timestamp,
        });
      }
    });
  }
}

Real-time + Push Combination Patterns

ในแอปจริง Real-time (WebSocket) และ Push Notification ทำงานร่วมกัน โดยมีหลักการว่า: เมื่อ App อยู่ Foreground ใช้ WebSocket เมื่อ App อยู่ Background ใช้ Push Notification

// Hybrid Notification Manager
class HybridNotificationManager {
  private isAppForeground = false;
  private wsClient: WebSocket | null = null;

  // Server ตัดสินใจว่าจะส่งผ่านทางไหน
  async notifyUser(userId: string, notification: NotificationPayload) {
    // ตรวจสอบว่า User มี Active WebSocket Connection หรือไม่
    const hasActiveWs = await this.wsManager.isUserConnected(userId);

    if (hasActiveWs) {
      // App อยู่ Foreground — ส่งผ่าน WebSocket (เร็วกว่า)
      await this.wsManager.sendToUser(userId, {
        type: 'notification',
        payload: notification,
      });
    } else {
      // App อยู่ Background — ส่งผ่าน Push Notification
      const tokens = await this.tokenService.getUserTokens(userId);
      await this.pushService.sendToDevices(tokens, notification);
    }
  }
}

Testing Push Notifications

// ทดสอบ Push ด้วย Firebase CLI
// 1. ติดตั้ง Firebase CLI
npm install -g firebase-tools

// 2. ส่ง Test Push
firebase messaging:send --project your-project-id \
  --json '{
    "message": {
      "token": "device-token-here",
      "data": {
        "type": "test",
        "title": "ทดสอบ Push",
        "body": "นี่คือ Push ทดสอบ"
      }
    }
  }'

// Unit Test — Push Service
describe('PushNotificationService', () => {
  it('should send push to valid token', async () => {
    const mockSend = jest.spyOn(admin.messaging(), 'send')
      .mockResolvedValue('message-id-123');

    const result = await pushService.sendToDevice('valid-token', {
      type: 'test',
      title: 'Test',
      body: 'Test body',
    });

    expect(result.success).toBe(true);
    expect(mockSend).toHaveBeenCalledWith(
      expect.objectContaining({
        token: 'valid-token',
      })
    );
  });

  it('should remove invalid token', async () => {
    jest.spyOn(admin.messaging(), 'send')
      .mockRejectedValue({
        code: 'messaging/registration-token-not-registered',
      });

    const removeSpy = jest.spyOn(tokenService, 'removeInvalidToken');

    await pushService.sendToDevice('invalid-token', payload);

    expect(removeSpy).toHaveBeenCalledWith('invalid-token');
  });
});

Notification Permission Best Practices

การขอ Permission อย่างชาญฉลาดเป็นกุญแจสำคัญ ถ้าขอตอนเปิดแอปครั้งแรกโดยไม่อธิบาย ผู้ใช้ส่วนใหญ่จะกด "ไม่อนุญาต" และเปลี่ยนใจทีหลังยากมาก

// Pre-permission Pattern (Flutter)
class NotificationPermissionScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.notifications_active, size: 80, color: Colors.blue),
          SizedBox(height: 24),
          Text(
            'ไม่พลาดทุกอัพเดต',
            style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
          ),
          SizedBox(height: 12),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 40),
            child: Text(
              'เปิดแจ้งเตือนเพื่อรับ:\n'
              '- สถานะคำสั่งซื้อแบบ Real-time\n'
              '- ข้อความจากร้านค้า\n'
              '- โปรโมชั่นพิเศษเฉพาะคุณ',
              textAlign: TextAlign.center,
            ),
          ),
          SizedBox(height: 32),
          ElevatedButton(
            onPressed: () async {
              final settings = await FirebaseMessaging.instance
                  .requestPermission();
              if (settings.authorizationStatus ==
                  AuthorizationStatus.authorized) {
                // Permission granted
                Navigator.pushReplacement(context,
                    MaterialPageRoute(builder: (_) => HomeScreen()));
              }
            },
            child: Text('เปิดแจ้งเตือน'),
          ),
          TextButton(
            onPressed: () {
              // Skip — ใช้ In-app notification แทน
              Navigator.pushReplacement(context,
                  MaterialPageRoute(builder: (_) => HomeScreen()));
            },
            child: Text('ไว้ทีหลัง'),
          ),
        ],
      ),
    );
  }
}

Notification Scheduling

// Local Notification Scheduling (Flutter — flutter_local_notifications)
class NotificationScheduler {
  final FlutterLocalNotificationsPlugin _plugin =
      FlutterLocalNotificationsPlugin();

  Future<void> scheduleNotification({
    required int id,
    required String title,
    required String body,
    required DateTime scheduledDate,
  }) async {
    await _plugin.zonedSchedule(
      id,
      title,
      body,
      tz.TZDateTime.from(scheduledDate, tz.local),
      NotificationDetails(
        android: AndroidNotificationDetails(
          'reminders', 'Reminders',
          importance: Importance.high,
          priority: Priority.high,
        ),
        iOS: DarwinNotificationDetails(
          presentAlert: true,
          presentBadge: true,
          presentSound: true,
        ),
      ),
      androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
      matchDateTimeComponents: DateTimeComponents.time,  // ทำซ้ำทุกวัน
    );
  }

  // ยกเลิก Notification
  Future<void> cancelNotification(int id) async {
    await _plugin.cancel(id);
  }

  // ยกเลิกทั้งหมด
  Future<void> cancelAll() async {
    await _plugin.cancelAll();
  }
}

สรุป — Push Notification & Real-time Checklist

หัวข้อเทคโนโลยีคำแนะนำ
Push ServiceFCM + APNsใช้ FCM เป็นหลัก รองรับทั้ง Android + iOS
PayloadData-onlyใช้ Data-only เพื่อควบคุมการแสดงผลเต็มที่
Token ManagementServer-side DBRefresh Token สม่ำเสมอ ลบ Token หมดอายุ
Deep LinkingCustom URL Schemeทุก Push ต้องมี Deep Link ไปหน้าจอที่ถูกต้อง
Rich NotificationBigPicture, Service Extensionเพิ่ม Engagement ด้วยรูปภาพและปุ่ม Action
Real-time ChatWebSocketใช้ WebSocket สำหรับ Chat, ส่งผ่าน Push เมื่อ Offline
Live DataSSE / Firebase RTDBSSE สำหรับ One-way, Firebase RTDB สำหรับ Sync
AnalyticsCustom + FirebaseTrack Sent, Delivered, Opened, CTR
FrequencyThrottling Serviceจำกัดจำนวน Push ต่อวัน/ชั่วโมง
PermissionPre-permission Screenอธิบายก่อนขอ ให้ทางเลือก Skip

Push Notification และ Real-time เป็นฟีเจอร์ที่ทำให้แอปมีชีวิต ผู้ใช้รู้สึกว่าแอปทำงานเพื่อเขาอยู่ตลอดเวลา แต่ถ้าใช้ไม่ดีจะเป็นดาบสองคม สิ่งสำคัญคือ ส่งสิ่งที่ผู้ใช้ต้องการ ในเวลาที่เหมาะสม ไปยังคนที่ใช่ สำหรับ Backend Architecture ที่เกี่ยวข้อง อ่านเพิ่มที่ คู่มือ TypeScript + Node.js และ คู่มือ Microservices


Back to Blog | iCafe Forex | SiamLanCard | Siam2R