كان عندنا موقع حيّ ومستقرّ: منتدى نسائي لمتابعة الحمل يعمل بالكامل على Firebase، يُحدَّث أسبوعياً أو أكثر. المطلوب: تطبيق أندرويد على Google Play. المسار البديهي في كل أدلة Capacitor هو بناء ملفات الموقع ثم حزمها داخل التطبيق، فيقرأها الـWebView من ذاكرة الجهاز. جرّبنا ذلك أولاً، وسقط سريعاً: كل تصحيح إملائي في نصّ واحد صار يعني إعادة بناء، ورفع نسخة، وانتظار مراجعة المتجر، ثم انتظار المستخدمات ليحدّثن التطبيق — بينما التصحيح نفسه ينشر على الموقع في دقيقة. فحوّلنا الاستراتيجية إلى «hosted hybrid»: التطبيق يحمّل النطاق الحيّ مباشرة عبر server.url.
نجح القرار، ثم كشف عن تكلفة لم يذكرها أي دليل قرأناه: التطبيق يعرض كل ما يعرضه الموقع — بما فيه الإعلانات. وصيغة إعلانية واحدة كانت تعمل على الموقع بلا مشكلة كفت لتعريض التطبيق للإيقاف. استغرق تشخيص هذه العلاقة — أن سياسات Google Play تحكم الآن كل سطر في موقعنا — وقتاً أطول من كتابة الغلاف نفسه. هذا المقال يوثّق القرارين: لماذا اخترنا النطاق الحيّ، وما الثمن الذي دفعناه في الكود مقابله.
القرار: حزم الأصول أم تحميل النطاق الحيّ
افتراضياً يبني Capacitor التطبيق حول مجلد أصول محلي يحدّده webDir، ويفتحه الـWebView من الجهاز — بلا شبكة، بأسرع زمن إقلاع، وبنسخة مثبّتة لا تتغيّر إلا بتحديث من المتجر. أما إعدادنا فيوجّه الـWebView إلى النطاق نفسه:
server: {
url: 'https://…' ,
cleartext: false,
androidScheme: 'https',
errorPath: 'index.html'
}
مع هذا التحويل لم يُحذف webDir، بل تغيّر دوره جوهرياً: صار صفحة احتياطية لا أكثر، يعرضها errorPath حين يفشل تحميل النطاق (انقطاع إنترنت قبل أول طلب). ومعه ثبّتنا قيوداً أمنية صريحة: cleartext: false وallowMixedContent: false — لا محتوى غير مشفّر داخل التطبيق أبداً، وهذا مهم لأن الـWebView صار يعرض صفحات من الشبكة لا من الجهاز.
متى يُختار كل أسلوب
- حزم الأصول محلياً: إن كان التطبيق يجب أن يعمل بلا إنترنت، أو محتواه مستقرّ، أو تريد أن يمرّ كل تغيير عبر اختبار ومراجعة قبل أن يصل للمستخدم.
- تحميل النطاق الحيّ: إن كان التطبيق يحتاج الشبكة أصلاً — حالتنا: كل شيء على Firestore بمستمعين لحظيين، فبلا إنترنت لا توجد شاشة مفيدة أساساً — والموقع نشط التحديث.
تفاصيل صغيرة حسمت جودة الإحساس: backgroundColor ضُبط على لون خلفية الموقع نفسه لتفادي وميض أبيض قبل ظهور الصفحة، وlaunchShowDuration: 0 عطّل شاشة بداية Capacitor لأن الجسر يخفيها يدوياً بعد جاهزية الصفحة. وأهمّ سطر لا رجعة فيه: appId — معرّف الحزمة لا يمكن تغييره بعد أول نشر على المتجر.
ما يشتريه لك server.url: تحديث فوري بلا مراجعة متجر
هذه هي الميزة التي لا تُعادلها أي راحة أخرى. أي إصلاح ننشره على الموقع يصل لمستخدمات التطبيق في نفس اللحظة: خطأ في حساب أسبوع الحمل، نصّ مضلّل في نموذج، قاعدة أمان تغيّرت، صفحة جديدة كاملة. لا بناء، لا رفع، لا مراجعة، ولا انتظار لمن يفتح المتجر ويحدّث. رقم إصدار التطبيق (versionCode) لم نعد نرفعه إلا حين تتغيّر الطبقة الأصلية فعلاً — إضافة جديدة، أيقونة، إذن.
لكن الفاتورة تُدفع في مكان آخر: الكاش. حين يكون الـWebView واجهتك الوحيدة، فإن أي ملف JavaScript أو CSS محفوظ بترويسة كاش طويلة الأمد يعني أن مستخدمة التطبيق قد تبقى على نسخة قديمة بعد نشرك. في مشروعنا كان لا بدّ من ترقية إصدار service worker ووسم روابط الأصول بمعامل إصدار مع كل نشرة، وإلا فالتحديث «الفوري» وهمٌ لا يراه أحد. تفاصيل هذا الجانب في مقال منفصل: إدارة إصدارات Service Worker.
server.url يلتزم مقابله بانضباط نشر أعلى: بناء واختبار قبل كل رفعة، وقدرة على التراجع السريع.
ما يكسره WebView: الجسر الأصلي ثمن الاختيار
الموقع الذي يعمل بلا عيب في كروم لا يعمل بالضرورة داخل غلاف تطبيق. ثلاثة أشياء انكسرت فوراً، ولكل واحد منها حلّ في ملف جسر واحد يُحمَّل ضمن صفحات الموقع:
- الدخول بقوقل:
signInWithPopupمحجوب داخل الـWebViewلأن قوقل يرفض المتصفحات المدمجة. الحل: إضافة مصادقة أصلية بإعدادskipNativeAuth: trueوproviders: ['google.com']— تُنتزع بيانات الاعتماد من الطبقة الأصلية، ثمGoogleAuthProvider.credential(idToken, accessToken)وsignInWithCredentialفي نفس حزمة JavaScript. النتيجة: بقية الموقع لا تعرف شيئاً؛ ترى المستخدمة عبرauth.currentUserكالمعتاد بلا تعديل سطر واحد في منطق الصلاحيات. ومن يواجه فشل الدخول برموز غامضة فليقرأ: تشخيص فشل الدخول بقوقل على أندرويد. - إشعارات الويب: لا تعمل داخل الغلاف، فحلّ محلّها FCM أصلي. والقرار المهم هنا توافقي لا تقني: خزّنا التوكن في نفس مسار الموقع وبنفس الصيغة (مصفوفة داخل وثيقة المستخدمة، بلا تكرار، وبحدّ أقصى خمسة أجهزة) — فعملت دالة الإشعارات السحابية القائمة بلا أي تعديل، بدل مضاعفة منطق الإرسال.
- الروابط الخارجية: رابط خارجي يُفتح داخل الغلاف يسجن المستخدمة في صفحة بلا شريط عنوان ولا زرّ رجوع. فالجسر يعترض النقر في مرحلة
captureويفتح كل رابطhttpsخارج نطاقنا في متصفح النظام — مع استثناءات مقصودة لنطاقات المصادقة، وإلا لانقطع تدفّق الدخول في منتصفه.
وأهمّ ما في هذا الجسر سطره الأول: حارس يخرج من الملف فوراً إذا لم نكن داخل منصّة أصلية. الملف نفسه يُحمَّل على الموقع لكل الزائرات، ويجب أن يكون أثره على الويب صفراً. ويضيف داخل التطبيق صنفاً على عنصر html ليمكن تكييف الواجهة في CSS دون فحوص جديدة في كل مكان.
التكلفة الخفية: التطبيق يعرض كل ما يعرضه الموقع
هذه النتيجة تبدو بديهية مكتوبةً، ولا يستحضرها أحد وقت القرار: باختيار النطاق الحيّ صار موقعك كله داخل نطاق سياسات Google Play. كل صفحة على الموقع = شاشة في التطبيق. كل نافذة منبثقة = نافذة في التطبيق. وكل سكربت طرف ثالث تحقنه في الموقع = سلوك يُحاسب عليه التطبيق. الأدلة تتحدّث عن الأيقونات وشاشة البداية والتوقيع، ولا تقول لك إن قسم الإعلانات في موقعك صار قضية نشر.
والنتيجة العملية الحاكمة في حالتنا كانت في الإعلانات تحديداً: إعلانات المواقع من نوع AdSense مخالفة صريحة داخل الـWebView — فهي مخصّصة للمواقع، والتطبيقات لها منتج آخر هو AdMob. لا يهمّ أن الكود «نفسه يعمل»؛ ظهوره داخل تطبيق منشور على المتجر هو الانتهاك. وترتّب على ذلك قرار معماري: قبل أي تفعيل لإعلانات الويب يجب أن يُستثنى التطبيق منها برمجياً، لا اعتماداً على النية. وتفاصيل التجربة بأرقامها في إعلانات المواقع العربية.
server.url، اسأل عن كل سكربت طرف ثالث تضيفه للموقع سؤالاً واحداً قبل الدمج: «هل يجوز ظهور هذا داخل تطبيق على المتجر؟». إن كان الجواب لا، فاشترط تشغيله على الويب فقط — في نقطة الحقن، لا في التوثيق.
كشف البيئة الأصلية بشرط واحد
الكشف نفسه سطر واحد، لكن تفصيلته تحمل قراراً. الدالة في مشروعنا تفحص مصدرين لا مصدراً واحداً: واجهة Capacitor المحقونة في الصفحة، والعلامة التي ينشرها جسرنا بعد أن يتحقّق فعلاً أنه يعمل داخل التطبيق:
function isNativeApp() {
try { return !!(window.Capacitor?.isNativePlatform?.() || window.WasanNative?.isNative); }
catch (_) { return false; }
}
ثلاث ملاحظات على هذه الأسطر الأربعة:
- الاستدعاء الاختياري مرّتين (
?.على الكائن وعلى الدالة) مقصود: على الويب لا وجود لـCapacitorإطلاقاً، وفي لحظات إقلاع نادرة قد يوجد الكائن قبل دوالّه. - المصدر الثاني ليس تكراراً عبثياً: هو تأكيد مستقلّ يجعل الفحص صامداً لو تغيّر ترتيب حقن الجسر بين إصدارات المكتبة.
- عند أي استثناء يرجع
false— أي «نحن على الويب»، وهو الاتجاه الآمن للسقوط هنا.
ولا تعتمد على User-Agent لهذا الكشف: قابل للتحريف، ويتغيّر مع نسخ الـWebView، ويُخطئ في الاتجاه الخطر.
بوابة واحدة لا فحوص متكرّرة
الخطأ الذي تفاديناه هو نشر if في كل موضع يستدعي الإعلانات. الحقن يحدث في دالة واحدة، وترتيب حرّاسها من الأرخص إلى الأغلى: عَلَم «حُقن مرّة واحدة لكل تحميل صفحة»، ثم أن الإعلانات مفعّلة أصلاً في الإعدادات، ثم أن الفتحة العامة مفعّلة، ثم أن الكود غير فارغ، وأخيراً if (isNativeApp()) return; قبل أول لمسة للـDOM. سبب حصر الشرط هنا عملي: نداء الحقن يأتي من مستمع لحظي على وثيقة إعدادات الإعلانات، فيُعاد تشغيله مع كل تغيير تجريه لوحة الإدارة. وضع الشرط في نقطة الحقن الوحيدة يجعل نسيانه مستحيلاً، بينما توزيعه على نقاط النداء يجعل نسيانه مسألة وقت.
وليس كل شيء يُحجب: بطاقات المنتجات ذات روابط العمولة تبقى في الاثنين، لأنها روابط https عادية بوسم rel="sponsored nofollow"، بلا سكربتات ولا نوافذ ولا محاولة خروج من التطبيق. ومن كان لديه CSP صارم فسيحتاج ضبطاً مقابلاً لهذه المصادر: سياسة أمان المحتوى مع أطراف ثالثة.
الصيغ التي قد تُوقف تطبيقك
ليست كل صيغ الإعلانات متساوية الخطر داخل الغلاف. الأخطر ما يفعل أحد أمرين: يفتح نافذة أو صفحة خارج التطبيق، أو يحاكي واجهة النظام — شريط يشبه شريط الإشعارات، أو تنبيه يبدو صادراً من الجهاز لا من صفحة. هذه بالتحديد أعلى الصيغ عائداً، ولهذا يسهل الانجرار إليها؛ وهي بالتحديد ما يُدرج تحت الإعلانات المضلّلة أو الظاهرة خارج التطبيق، وعقوبتها قد تصل إلى إيقاف التطبيق لا مجرد تحذير.
والمفارقة الهندسية أن هذه الصيغ هي نفسها التي لا يمكن عزلها: بقية فتحاتنا تعمل داخل إطار معزول صغير، أما هذه فتحتاج مستوى الصفحة كاملاً (عناصر مثبّتة بـposition:fixed أو نافذة جديدة)، فتُقصّ داخل إطار بعرض ٣٢٠ بكسل ولا تُحتسب. لذلك تُحقن في body مباشرة — ولهذا بالضبط تحتاج بوابة صريحة تمنعها داخل التطبيق.
الإعلان البيني: مقبول بشروط نكتبها بأنفسنا
الصيغة البينية (نافذة تظهر بين شاشتين) ليست ممنوعة، لكن سوء استخدامها هو أشهر أسباب المشاكل. القيود التي فرضناها في الكود لا في التوثيق:
- قابلة للإغلاق دائماً، ولا تظهر إلا في لحظة منطقية: تنقّل بين تبويبات، أو بعد إتمام إجراء.
- سقوف صارمة مجتمعة: لا تظهر إلا كل ثلاث تنقّلات، وبحدّ أقصى خمس مرّات في الجلسة، وبفترة تبريد بالدقائق تُخزَّن في
localStorageفتصمد بين الجلسات. - لا تُفتح النافذة إلا بعد التأكّد أن الإطار امتلأ بإعلان فعلي: فحص دوري كل نصف ثانية حتى نحو ثماني ثوان، وإن لم يصل شيء تُلغى بلا احتساب. نافذة فارغة تُغضب المستخدمة وتُقرأ كخلل في التطبيق، لا كإعلان لم يُعبَّأ.
تحقّق البناء: JAVA_HOME والـ JBR
آخر عقبة لم تكن في الكود بل في البناء، وهي من أكثر ما يوقف المطوّرين بلا رسالة واضحة. نصّ gradlew يبحث عن جافا في JAVA_HOME أولاً، وسلوكه يختلف بحسب حالته:
- غير مضبوط ولا جافا في المسار: رسالة صريحة
JAVA_HOME is not set and no 'java' command could be found in your PATH— مزعجة لكنها واضحة. - مضبوط على مجلد غير موجود:
JAVA_HOME is set to an invalid directory— وهذا ما يحدث كثيراً بعد إلغاء تثبيت حزمة JDK قديمة بينما بقي المتغيّر يشير إليها. - الأسوأ: مضبوط على نسخة JDK موجودة لكن غير مناسبة. هنا لا تحصل على أي كلام عن جافا، بل على فشل غامض في Gradle أو في إضافة أندرويد، فتضيّع ساعة تبحث في الإضافات والمكتبات والمشكلة في متغيّر بيئة.
الحلّ الذي استقرّ عليه مشروعنا: استخدام حزمة JDK المرفقة مع Android Studio (تُعرف بـjbr) بدل تثبيت مستقلّ — فهي نفس النسخة التي تُبنى بها المشاريع داخل البيئة الرسومية، فيتّحد سلوك الطرفين. وعلى PowerShell تذكّر أن المتغيّرات لا تُحفظ بين استدعاءات منفصلة، فاضبطه في نفس الأمر:
$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
.\gradlew bundleRelease
نفس الحزمة تحمل أدوات مكمّلة تحتاجها لاحقاً: أداة التحقّق من توقيع الحزمة قبل الرفع، وأداة استخراج بصمات التوقيع (SHA-1 وSHA-256) التي يلزمك تسجيلها في Firebase — وهي بابٌ لخطأ آخر شهير مشروح في لماذا SHA-1 لا يكفي. وقبل أي بناء: مزامنة الإعداد والإضافات مع مشروع أندرويد، ورفع رقم الإصدار إن كنت ترفع نسخة جديدة. ودرس إضافي من مشروعنا: دليل النشر الداخلي كان يشير لمسار JDK لم يبق على الجهاز، فكان يضلّل كل من يتبعه حرفياً — راجع أدلّتك الداخلية كما تراجع كودك.
أسئلة شائعة
هل يُقبل تطبيق «مجرد موقع» على المتجر؟ الغلاف الذي لا يضيف شيئاً فوق المتصفح معرّض للرفض تحت بند الوظيفة الأدنى. ما جعل تطبيقنا تطبيقاً حقيقياً: إشعارات أصلية، دخول أصلي، حفظ ملف ومشاركته عبر أدوات النظام، وزرّ رجوع بمنطق سليم (رجوع في التاريخ، وخروج عند آخر شاشة). وللعقبات الأخرى: نشر تطبيق على Google Play.
ماذا يرى المستخدم إن انقطع الإنترنت قبل التحميل؟ الصفحة المحلية التي يشير إليها errorPath داخل مجلد الأصول الاحتياطي. لا تتركها الصفحة الافتراضية الفارغة؛ اجعلها رسالة مفهومة بهوية التطبيق، فهي أول ما تراه المستخدمة في أسوأ لحظة.
هل أخسر عائد إعلانات الويب داخل التطبيق؟ نعم، وهي خسارة مقصودة لا عيب في التنفيذ. عائد التطبيق يأتي من منتج الإعلانات الخاص بالتطبيقات، أو من صيغ محايدة تعمل في البيئتين مثل بطاقات العمولة.
كيف أشخّص الصفحة داخل التطبيق دون بناء في كل مرّة؟ بتفعيل تنقيح محتوى الـWebView في إعداد أندرويد، فتفتح أدوات مطوّري كروم على الجهاز الموصول وتفحص الصفحة الحقيقية. واجعله قراراً واعياً قبل النشر النهائي، لا إعداداً منسيّاً في نسخة الإصدار.
خلاصة
تحميل النطاق الحيّ قرار صحيح حين يحتاج موقعك الشبكة أصلاً ويتغيّر بسرعة: تكسب تحديثاً فورياً بلا مراجعة متجر، وتدفع ثلاثة أثمان يجب أن تدخل الحساب مسبقاً. الأول تقني: ما يعمل في المتصفح لا يعمل بالضرورة في الغلاف، فتحتاج جسراً أصلياً للمصادقة والإشعارات والروابط وزرّ الرجوع. والثاني سياسي: موقعك كله صار خاضعاً لسياسات المتجر، فإعلانات المواقع ممنوعة داخل الغلاف، والصيغ التي تخرج عن صفحتك أو تحاكي النظام خطر على بقاء التطبيق — والحلّ سطر كشف واحد في بوابة الحقن الوحيدة، لا وعدٌ في التوثيق. والثالث تشغيلي: انضباط في الكاش وإصدارات الأصول، وبيئة بناء مضبوطة على حزمة JDK واحدة معروفة.