تخطَّ إلى المحتوى
أسامة جنينة
مقالات
5 دقائق قراءة

Flutter و BLE عبر Pigeon بدل method channels

تطبيق mesh عبر البلوتوث فيه كود راديو أصلي على منصتين وحدّ عريض مع Dart. قنوات method channels اليدوية جعلت ذلك الحدّ مقامرة في وقت التشغيل؛ وتوليده بـ Pigeon جعله خطأ تصريف.

FlutterDartPigeonBluetooth Low EnergyKotlinSwift

«صِلة» تطبيق مراسلة لحين لا يوجد إنترنت: الهواتف تمرّر الرسائل فيما بينها عبر Bluetooth Low Energy، قفزةً بقفزة، بلا أي سيرفر. Flutter يملك الواجهة والتخزين المحلي وسياسة التوجيه. أما عمل الراديو فلا — ذلك كود native، مكتوب مرة لأندرويد ومرة لـ iOS.

وهذا يرسم حدّاً في منتصف التطبيق تماماً. كل ما يعرفه الراديو يجب أن يعبره إلى Dart، وكل ما يقرره Dart يجب أن يعبره عائداً. هذه المقالة عن طريقة بناء ذلك الحدّ، لأنه كان في هذا المشروع القرار الأعلى مردوداً.

لماذا الراديو native أصلاً

كان الأبسط تنفيذ البلوتوث من Dart عبر إضافة جاهزة والإبقاء على قاعدة كود واحدة. ولتطبيق يتحدث مع ملحق واحد، هذا ينجح.

لكن عقدة الـ mesh ليست ذلك التطبيق. كل هاتف هو peripheral و central في الوقت نفسه: يُعلن ويقبل الاتصالات وهو يمسح ويتصل بالخارج أيضاً. وعليه أن يستمر في الاثنين في الخلفية، وهنا بالضبط تفترق المنصتان. التنفيذ في الخلفية ودورات حياة الإعلان والاتصال تختلف بين أندرويد و iOS أكثر من أن تُخفى خلف تجريد مشترك.

فهناك تنفيذان. Kotlin يتولى الإعلان والمسح وخادم وعميل GATT والتنفيذ في الخلفية. و Swift يؤدي دورَي peripheral و central في CoreBluetooth، تحت قيود الخلفية في iOS. وقبول ذلك مبكراً كان أرخص من مصارعة تجريد واحد لأشهر.

الحدّ ليس استدعاءين

لو كانت الطبقة الأصلية تعرض start() و stop() فقط، لما كان لأي من هذا أهمية.

لكنها تحمل أكثر بكثير: حالة الاتصال، وأحداث اكتشاف النظائر، ونتائج القراءة والكتابة في GATT، وكل حالة خطأ — في الاتجاهين، وعلى منصتين. هذه واجهة API حقيقية، وهي مُنفَّذة ثلاث مرات: في Dart وفي Kotlin وفي Swift.

كم تكلّف القناة المكتوبة يدوياً

أداة Flutter القياسية لهذا هي method channel. من جهة Dart:

final delivered = await channel.invokeMethod<bool>('send', {
  'peerId': peer.id,
  'payload': bytes,
});

ومن جهة أندرويد:

when (call.method) {
    "send" -> {
        val peerId = call.argument<String>("peerId")
        val payload = call.argument<ByteArray>("payload")
        // …
    }
}

انظر إلى ما يربط هذين معاً: النص 'send'، والنص 'peerId'، والأمل في أن يتفق الملفان عليهما. لا شيء يتحقق من ذلك. غيّر اسم مفتاح في جهة واحدة فتبقى الجهتان تُصرَّفان، وتبقى اختبارات كل جهة ناجحة، ويبقى التطبيق يفتح.

ثم يفشل لاحقاً — في وقت التشغيل، على جهاز، في مسار الكود الوحيد الذي يستخدم ذلك الاستدعاء. ولتطبيق غايته أن يعمل حين لا يعمل أي شيء آخر، مكان ظهور ذلك الفشل هو يد أحدهم، في الميدان.

وصفُه مرة واحدة

Pigeon يحوّل العقد إلى ملف. تصف الـ API في Dart، فيولّد كود Dart و Kotlin و Swift للجهتين. التعريف أدناه مختصر ليُظهر الشكل:

import 'dart:typed_data';
 
import 'package:pigeon/pigeon.dart';
 
class Peer {
  Peer({required this.id, required this.rssi});
 
  final String id;
  final int rssi;
}
 
enum LinkState { connecting, connected, disconnected }
 
/// يستدعيها Dart. وينفّذها Kotlin و Swift.
@HostApi()
abstract class MeshRadio {
  void startAdvertising();
  void startScanning();
 
  @async
  bool send(String peerId, Uint8List payload);
}
 
/// يستدعيها Kotlin و Swift. وينفّذها Dart.
@FlutterApi()
abstract class MeshRadioEvents {
  void onPeerDiscovered(Peer peer);
  void onLinkStateChanged(String peerId, LinkState state);
  void onPayloadReceived(String peerId, Uint8List payload);
}

وأمر واحد يعيد توليد الجهات الثلاث:

dart run pigeon --input pigeons/mesh_radio.dart

وفي أندرويد، الكود المولَّد واجهةٌ تنفّذها، لا نصٌّ تطابقه:

class AndroidMeshRadio : MeshRadio {
    override fun startAdvertising() { /* BluetoothLeAdvertiser */ }
 
    override fun startScanning() { /* BluetoothLeScanner */ }
 
    override fun send(peerId: String, payload: ByteArray, callback: (Result<Boolean>) -> Unit) {
        // اكتب في characteristic النظير؛ وأبلغ بالنتيجة عبر الـ callback.
    }
}

والآن غيّر send لتأخذ وسيطاً ثالثاً. مواضع الاستدعاء في Dart تتوقف عن التصريف. وصنف Kotlin لم يعد يحقق واجهته. وكذلك صنف Swift. صار تغيير التوقيع خطأ تصريف على الأسطح الثلاثة في اللحظة نفسها، قبل تثبيت أي شيء على هاتف.

ما الذي لا يفعله

لا يجعل التنفيذين الأصليين متطابقين. Kotlin و Swift يبقيان مختلفين حيثما اختلفت المنصتان، وهي أغلب المواضع المهمة. التنفيذ الخلفي للبلوتوث هو المكان الذي تموت فيه التجريدات العابرة للمنصات، و Pigeon ليس تجريداً فوق البلوتوث. إنه ضمانة عن الحدّ، ولا شيء غير ذلك.

واتضح أن هذا هو المقدار الصحيح. الأجزاء التي يجب أن تختلف حرّة في أن تختلف؛ والجزء الذي يجب أن يتفق لا يستطيع أن يختلف.

ما الذي كسبه

انتقلت الأعطال من وقت التشغيل إلى وقت التصريف. وفي مشروع قيمته كلها هي الموثوقية عندما لا يعمل أي شيء آخر، كان ذلك أعلى قرار مردوداً.

وهو يمنح جهة Dart أرضاً ثابتة تقف عليها. سياسة التوجيه تتحدث مع واجهة لا مع راديو، فيستطيع الاختبار أن يسلّمها راديو وهمياً دون أن يلمس البلوتوث. التطبيق 69 ملف Dart مع 15 ملف اختبار، فوق تنفيذ أصلي لكل منصة خلف تلك الواجهة المولَّدة الواحدة.

إذا كان تطبيق Flutter عندك يعبر إلى الكود الأصلي مرة واحدة، فـ method channel تكفي. أما إذا كان العبور API — أحداثاً ونتائج وأخطاء، في الاتجاهين — فاكتبه مرة واحدة وولّده. الخطأ الذي تمنعه هو الذي لا يظهر إلا على جهاز ليس في يدك.