عندما بنيت أول تطبيق موبايل، ظننت أنني أعرف كل شيء. لكن بعد ٣ مشاريع فاشلة و٤٠٠ ساعة تصحيح أخطاء، اكتشفت أن الأخطاء التقنية ليست مجرد bugs — إنها قرارات تصميم خاطئة تؤجل الفشل فقط. إليك ما لا يخبرك به أحد عن الـ Memory Leaks، الـ Event Loop، والـ I/O Bound في عالم الموبايل.
في عام ٢٠٢١، أطلقنا تطبيق توصيل طلبات في دبي باستخدام React Native. بعد شهرين من الإطلاق، بدأ المستخدمون يشتكون من أن التطبيق "يعلق" بعد ١٠ دقائق من الاستخدام. ظننا في البداية أن المشكلة في الـ API، لكن الحقيقة كانت أسوأ: كنا نستهلك ٣٠٠ ميجابايت من الذاكرة في الخلفية بسبب تسريب بسيط في الـ Image Caching. هذا الخطأ وحده كلفنا ١٥٪ من قاعدة المستخدمين قبل أن نكتشفه. المشكلة ليست في أنك لا تعرف كيف تكتب كوداً — المشكلة أنك لا تعرف كيف تكتب كوداً يتحمل ضغط الاستخدام الحقيقي.
في هذا المقال، لن أتحدث عن الأساسيات مثل اختيار بين Flutter وReact Native. سأريك الأخطاء التي يقع فيها حتى المطورون ذوو الخمس سنوات خبرة، والتي لا تظهر إلا بعد إطلاق التطبيق في الإنتاج. هذه الأخطاء ليست مجرد bugs — إنها قرارات تصميم خاطئة تتسلل إلى الكود منذ اليوم الأول، وتكبر مثل سرطان حتى تدمر تجربة المستخدم بالكامل. سأريك كيف يحدث ذلك، وكيف يمكنك تجنبه من البداية.
في عالم الويب، نحن معتادون على أن الـ Event Loop يتعامل مع كل شيء بشكل سحري. لكن في الموبايل، الأمور مختلفة تماماً. عندما تكتب كوداً مثل setTimeout أو Promises في تطبيق موبايل، فأنت لا تتعامل فقط مع الـ JavaScript Engine — بل مع الـ Native Bridge أيضاً. هذا الجسر بين الـ JavaScript والـ Native Code هو نقطة الاختناق الرئيسية في أي تطبيق هجين. مثلاً، في React Native، كل call إلى Native Modules يمر عبر هذا الجسر، وإذا قمت بعمل ٥٠٠ call متتالي، فأنت في مشكلة حقيقية.
المشكلة الأكبر هي أن معظم المطورين لا يدركون أن الـ Event Loop في الموبايل ليس مجرد حلقة واحدة. في Android مثلاً، هناك Main Thread وBackground Threads وRender Thread. عندما تكتب كوداً مثل هذا:
// ❌ خطأ شائع: استخدام setTimeout لتأخير تنفيذ الكود
componentDidMount() {
// هذا الكود سيُنفذ في Main Thread، وقد يسبب تجميد الواجهة
setTimeout(() => {
this.setState({ data: heavyComputation() });
}, 1000);
}
// ✅ الحل الصحيح: استخدام InteractionManager
import { InteractionManager } from 'react-native';
componentDidMount() {
InteractionManager.runAfterInteractions(() => {
// هذا الكود سينفذ بعد انتهاء الـ Animations والـ Touch Events
this.setState({ data: heavyComputation() });
});
}في المثال الأول، setTimeout لا يضمن أن الكود سينفذ في Background Thread. في الواقع، في معظم الحالات، سينفذ في Main Thread، مما يسبب تجميد الواجهة. أما InteractionManager.runAfterInteractions، فهو مصمم خصيصاً لتطبيقات الموبايل، ويضمن أن الكود سينفذ بعد انتهاء جميع الـ Animations والـ Touch Events، مما يحافظ على سلاسة التطبيق.
الحقيقة الصادمة هي أن معظم مكتبات الـ State Management مثل Redux وMobX لا تأخذ هذا الأمر في الاعتبار. عندما تستخدم هذه المكتبات في تطبيقات الموبايل، فأنت بحاجة إلى أن تكون حذراً جداً مع الـ Middleware والـ Side Effects. مثلاً، إذا كتبت middleware في Redux يقوم بعمل API call ثم تحديث الـ State، فأنت تخاطر بتجميد الواجهة إذا لم تستخدم آلية مثل redux-saga أو redux-thunk بشكل صحيح.
في عام ٢٠٢٠، عملت على تطبيق صحة يستخدم Flutter. بعد شهر من الإطلاق، بدأ المستخدمون يشتكون من أن التطبيق "يصبح بطيئاً" بعد استخدامه لفترة طويلة. بعد التحقيق، اكتشفنا أن التطبيق كان يستهلك ١.٢ جيجابايت من الذاكرة بعد ساعة من الاستخدام — وهذا على جهاز iPhone 11! المشكلة؟ كنا نستخدم ListView.builder بدون Key لكل عنصر، مما تسبب في تسريب ذاكرة هائل بسبب عدم تحرير الـ Widgets القديمة.
الـ Memory Leaks في تطبيقات الموبايل ليست مثل تلك في الويب. في الويب، إذا حدث تسريب ذاكرة، يمكنك ببساطة إعادة تحميل الصفحة. لكن في الموبايل، التطبيق يبقى مفتوحاً لساعات، وأحياناً أيام، مما يعني أن أي تسريب صغير سينمو بمرور الوقت حتى يدمر تجربة المستخدم. المشكلة الأكبر هي أن معظم المطورين لا يدركون أن الـ Garbage Collector في الموبايل لا يعمل بنفس كفاءة الـ Garbage Collector في الويب.
// ❌ خطأ شائع في Flutter: عدم استخدام Key في ListView
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(items[index].name));
},
);
// ✅ الحل الصحيح: استخدام Key لكل عنصر
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
key: ValueKey(items[index].id), // هذا يمنع تسريب الذاكرة
title: Text(items[index].name),
);
},
);في المثال الأول، عندما يتغير الـ items، فإن Flutter لا يعرف أي عنصر تم تغييره وأي عنصر بقي كما هو، مما يؤدي إلى إعادة بناء جميع الـ Widgets وإعادة تسجيل الـ Listeners، مما يسبب تسريب ذاكرة. أما في المثال الثاني، فإن استخدام ValueKey يسمح لـ Flutter بمعرفة بالضبط أي عنصر تم تغييره، مما يمنع إعادة بناء الـ Widgets غير الضرورية ويحافظ على الذاكرة.
لكن الـ Memory Leaks لا تقتصر على الـ Lists فقط. أحد أكثر الأخطاء شيوعاً هو عدم إلغاء الاشتراك في الـ Event Listeners. مثلاً، في تطبيقات React Native، إذا قمت بالتسجيل في حدث مثل Keyboard.addListener دون إلغاء الاشتراك عند إلغاء تحميل الـ Component، فأنت تسبب تسريب ذاكرة. هذا التسريب قد يبدو صغيراً في البداية، لكنه سينمو بمرور الوقت حتى يصبح مشكلة حقيقية.
// ❌ خطأ شائع: عدم إلغاء الاشتراك في الـ Event Listeners
componentDidMount() {
this.keyboardDidShowListener = Keyboard.addListener(
'keyboardDidShow',
this._keyboardDidShow
);
}
// ✅ الحل الصحيح: إلغاء الاشتراك عند إلغاء تحميل الـ Component
componentWillUnmount() {
if (this.keyboardDidShowListener) {
this.keyboardDidShowListener.remove();
}
}في أحد المشاريع التي عملت عليها، كان لدينا تطبيق يستخدم SQLite لتخزين البيانات المحلية. كنا نقوم بجلب ٥٠٠٠ سجل من قاعدة البيانات وعرضها في قائمة. المشكلة؟ التطبيق كان "يتعطل" لمدة ثانيتين عند فتح الشاشة. ظننا في البداية أن المشكلة في الـ UI، لكن الحقيقة كانت في الـ Database Query نفسها. كنا نقوم بعمل JOIN على ثلاث جداول بدون أي فهارسة، مما جعل الـ Query يستغرق ١.٥ ثانية على جهاز متوسط المواصفات.
الـ I/O Bound Tasks هي واحدة من أكبر المشاكل في تطبيقات الموبايل. عندما تقوم بعمل قراءة أو كتابة من قاعدة بيانات، أو قراءة ملف من الـ Storage، أو حتى عمل API call، فأنت تقوم بعمل I/O، وهذا النوع من المهام يمكن أن يستغرق وقتاً طويلاً جداً. المشكلة الأكبر هي أن معظم المطورين لا يدركون أن هذه المهام لا تعمل في الخلفية بشكل تلقائي — بل تحتاج إلى معالجة خاصة.
// ❌ خطأ شائع في Android: تنفيذ I/O في Main Thread
fun loadData() {
val db = database.readableDatabase
val cursor = db.rawQuery("SELECT * FROM large_table", null)
// هذا الكود سينفذ في Main Thread، مما يسبب تجميد الواجهة
}
// ✅ الحل الصحيح: استخدام Coroutines أو RxJava
fun loadData() {
viewModelScope.launch(Dispatchers.IO) {
val db = database.readableDatabase
val cursor = db.rawQuery("SELECT * FROM large_table", null)
withContext(Dispatchers.Main) {
// تحديث الواجهة بعد انتهاء الـ Query
}
}
}في المثال الأول، يتم تنفيذ الـ Database Query في Main Thread، مما يسبب تجميد الواجهة. أما في المثال الثاني، فإن استخدام Coroutines يسمح بتنفيذ الـ Query في Background Thread، ثم تحديث الواجهة في Main Thread بعد انتهاء الـ Query. هذا الفرق البسيط يمكن أن يحول تجربة مستخدم سيئة إلى تجربة سلسة تماماً.
لكن الـ I/O Bound Tasks لا تقتصر على قواعد البيانات فقط. حتى قراءة ملف صغير من الـ Storage يمكن أن تسبب مشاكل إذا لم يتم التعامل معها بشكل صحيح. مثلاً، في تطبيقات Flutter، إذا قمت بقراءة ملف باستخدام File.readAsStringSync، فأنت تسبب تجميد الواجهة. الحل هو استخدام File.readAsString، الذي يعمل بشكل غير متزامن.
في أحد المشاريع، كان لدينا تطبيق يستخدم GPS لتتبع موقع المستخدم في الخلفية. بعد أسبوع من الإطلاق، بدأ المستخدمون يشتكون من أن البطارية تنفد بسرعة كبيرة. بعد التحقيق، اكتشفنا أن التطبيق كان يستهلك ٢٠٪ من البطارية في الساعة — وهذا على جهاز حديث! المشكلة؟ كنا نستخدم الـ GPS كل ثانية، حتى عندما كان التطبيق في الخلفية، مما تسبب في استنزاف البطارية بسرعة كبيرة.
الـ Battery Life هو أحد أهم العوامل التي تؤثر على تجربة المستخدم في تطبيقات الموبايل. عندما يستهلك تطبيقك بطارية الجهاز بسرعة، فإن المستخدمين ببساطة سيقومون بإلغاء تثبيته. المشكلة الأكبر هي أن معظم المطورين لا يدركون أن الـ Background Processes يمكن أن تستهلك بطارية الجهاز بسرعة كبيرة، حتى لو كان التطبيق مغلقاً.
// ❌ خطأ شائع في iOS: استخدام الـ GPS بشكل مستمر
func startUpdatingLocation() {
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.startUpdatingLocation()
// هذا الكود سيستمر في استخدام الـ GPS حتى لو كان التطبيق في الخلفية
}
// ✅ الحل الصحيح: استخدام الـ Significant Location Change
func startMonitoringSignificantLocationChanges() {
locationManager.startMonitoringSignificantLocationChanges()
// هذا الكود يستخدم أقل قدر ممكن من البطارية
}في المثال الأول، يتم استخدام الـ GPS بشكل مستمر، مما يستهلك بطارية الجهاز بسرعة كبيرة. أما في المثال الثاني، فإن استخدام startMonitoringSignificantLocationChanges يسمح للتطبيق باستقبال تحديثات الموقع فقط عندما يتحرك المستخدم مسافة كبيرة، مما يقلل من استهلاك البطارية بشكل كبير.
لكن الـ Battery Life لا يتأثر فقط بالـ GPS. حتى الـ Network Calls يمكن أن تستهلك بطارية الجهاز بسرعة إذا لم يتم التعامل معها بشكل صحيح. مثلاً، إذا كان تطبيقك يقوم بعمل API call كل ٥ ثوانٍ، حتى عندما يكون التطبيق في الخلفية، فأنت تستهلك بطارية الجهاز بدون داعٍ. الحل هو استخدام آليات مثل الـ Background Fetch في iOS أو الـ WorkManager في Android، التي تسمح للتطبيق بالعمل في الخلفية بشكل فعال دون استنزاف البطارية.
في عام ٢٠١٩، أطلقنا تطبيق توصيل طلبات في القاهرة. بعد أسبوع من الإطلاق، بدأنا نتلقى شكاوى من المستخدمين في المناطق ذات الاتصال الضعيف بالإنترنت. التطبيق ببساطة "يتعطل" عندما يفقد الاتصال، ولا يعمل حتى يعود الاتصال. المشكلة؟ لم نفكر أبداً في تجربة المستخدم بدون إنترنت. عندما فقد المستخدم الاتصال، كان التطبيق يعرض شاشة بيضاء بدون أي رسالة خطأ، مما جعل المستخدمين يعتقدون أن التطبيق معطل.
الـ Offline Experience هو أحد أهم العوامل التي تفرق بين التطبيقات الجيدة والتطبيقات الرائعة. في عالم الموبايل، لا يمكنك افتراض أن المستخدم لديه اتصال إنترنت مستقر دائماً. حتى في المدن الكبيرة، هناك مناطق ذات اتصال ضعيف، أو شبكات مزدحمة، أو حتى مشاكل في مزود الخدمة. إذا لم تفكر في تجربة المستخدم بدون إنترنت، فأنت تخاطر بفقدان المستخدمين بسرعة كبيرة.
// ❌ خطأ شائع: عدم التعامل مع حالة عدم وجود اتصال
async function fetchData() {
const resp await fetch('https://api.example.com/data');
const data = await response.json();
this.setState({ data });
// إذا فقد المستخدم الاتصال، فسيظهر خطأ بدون أي تفسير
}
// ✅ الحل الصحيح: استخدام مكتبة مثل redux-offline أو React Query
import { useQuery } from 'react-query';
function MyComponent() {
const { data, error, isLoading } = useQuery('data', fetchData, {
retry: 3, // إعادة المحاولة ٣ مرات قبل إظهار الخطأ
staleTime: 5 * 60 * 1000, // البيانات تبقى "طازجة" لمدة ٥ دقائق
});
if (isLoading) return <Loading />;
if (error) return <OfflineScreen />; // شاشة مخصصة لحالة عدم وجود اتصال
return <DataScreen data={data} />;
}في المثال الأول، إذا فقد المستخدم الاتصال، فسيظهر خطأ بدون أي تفسير، مما يسبب تجربة مستخدم سيئة. أما في المثال الثاني، فإن استخدام مكتبة مثل React Query يسمح بالتعامل مع حالات عدم وجود اتصال بشكل أفضل، مثل إعادة المحاولة تلقائياً، وعرض شاشة مخصصة لحالة عدم وجود اتصال، والحفاظ على البيانات "طازجة" لمدة معينة حتى يعود الاتصال.
لكن الـ Offline Experience لا يقتصر على التعامل مع الأخطاء فقط. يجب أيضاً التفكير في كيفية عمل التطبيق بدون اتصال من البداية. مثلاً، إذا كان تطبيقك يعتمد على بيانات من الـ API، فيجب أن تفكر في كيفية تخزين هذه البيانات محلياً باستخدام قاعدة بيانات مثل SQLite أو Realm، وكيفية مزامنة هذه البيانات عندما يعود الاتصال. هذا ليس أمراً سهلاً، لكنه ضروري لتقديم تجربة مستخدم سلسة.
بعد بناء أكثر من ١٠ تطبيقات موبايل وإصلاح أخطاء لا حصر لها، تعلمت أن الأخطاء الكبيرة لا تظهر في الكود — بل تظهر في القرارات التي تتخذها قبل كتابة أول سطر. عندما تبني تطبيق موبايل، تذكر هذه القواعد الذهبية:
إذا تذكرت شيئاً واحداً فقط من هذا المقال، فليكن هذا: بناء تطبيق موبايل ليس مجرد كتابة كود يعمل — بل هو كتابة كود يتحمل ضغط الاستخدام الحقيقي، ويتكيف مع ظروف العالم الحقيقي، ويقدم تجربة مستخدم سلسة حتى في أسوأ الظروف. إذا فعلت ذلك، فأنت لست مجرد مطور — بل مهندس موبايل حقيقي.