بعد ١٠ سنوات في تطوير الموبايل، رأيت مشاريع تسقط بسبب أخطاء بسيطة يمكن تجنبها. إليك الدروس التقنية القاسية التي تعلمتها من بناء ٥٠+ تطبيق، مع أمثلة حقيقية من تطبيقات مثل تيك توك وإنستاجرام.
في عام ٢٠٢٠، قضيت ثلاثة أشهر في بناء تطبيق تواصل اجتماعي من الصفر باستخدام ريأكت نيتف. بعد الإطلاق، انهار التطبيق تحت ضغط ٥٠٠ مستخدم متزامن — ليس بسبب ضعف السيرفر، بل لأنني تجاهلت خطأ بسيطاً في إدارة الـ State. المشكلة؟ استخدمت Context API لتخزين بيانات المستخدمين، مما تسبب في إعادة رسم جميع المكونات عند كل تحديث. النتيجة: استهلاك ذاكرة زائد بنسبة ٤٠٠٪ وتجمد الواجهة عند كل ضغطة زر. هذه ليست قصة درامية، بل حقيقة يومية يواجهها المطورون الذين يبنون تطبيقات موبايل دون فهم عميق لكيفية عمل المحرك تحت الغطاء.
الأخطاء في تطوير الموبايل ليست مجرد bugs يمكن إصلاحها لاحقاً. إنها قرارات تصميمية تتحول إلى قنابل موقوتة تنتظر اللحظة المناسبة للانفجار. في هذا المقال، سأفكك أكبر ٦ أخطاء ارتكبتها (وأرى غيري يرتكبها يومياً) في مشاريع حقيقية، مع شرح تقني دقيق لكيفية حدوثها ولماذا تدمر الأداء، وكيفية تجنبها من اليوم الأول.
الموبايل ليس كمبيوتر مكتبي. عندما تكتب كوداً في main thread في أندرويد أو main queue في آي أو إس، فأنت تعمل على نفس الـ Thread الذي يرسم الواجهة ويرد على لمسات المستخدم. في أحد مشاريعي السابقة، استخدمت مكتبة خارجية لتحميل الصور من الشبكة داخل loop في main thread. النتيجة؟ تجمد التطبيق لمدة ٣ ثوانٍ عند فتح الشاشة، مع رسالة ANR (Application Not Responding) في أندرويد. المشكلة ليست في المكتبة نفسها، بل في اعتقادي الخاطئ أن الـ Network Request خفيف ولن يؤثر على الأداء.
الحقيقة هي أن أي عملية تستغرق أكثر من ١٦ مللي ثانية (٦٠ إطار في الثانية) ستسبب تجمد الواجهة. في أندرويد، يجب استخدام Coroutines أو RxJava، وفي آي أو إس، DispatchQueue.global().async. لكن حتى هذه الحلول ليست كافية لوحدها. مثلاً، إذا قمت بتحميل ١٠٠ صورة في background thread ثم حاولت عرضها في main thread دفعة واحدة، ستحصل على تجمد آخر بسبب الـ Layout Pass الذي يحدث عند إضافة كل صورة. الحل؟ استخدم تقنيات مثل Diffing Algorithm في ريأكت نيتف أو RecyclerView في أندرويد لعرض البيانات تدريجياً.
// مثال خاطئ: تحميل الصور في main thread
fun loadImages() {
for (i in 0 until 100) {
val bitmap = BitmapFactory.decodeStream(URL("https://example.com/image$i.jpg").openStream())
imageViews[i].setImageBitmap(bitmap) // هذا سيجمد الواجهة
}
}
// الحل الصحيح: استخدام Coroutines مع Dispatchers.IO
fun loadImagesCorrectly() {
viewModelScope.launch(Dispatchers.IO) {
val bitmaps = mutableListOf<Bitmap>()
for (i in 0 until 100) {
val bitmap = BitmapFactory.decodeStream(URL("https://example.com/image$i.jpg").openStream())
bitmaps.add(bitmap)
}
withContext(Dispatchers.Main) {
// استخدم DiffUtil لعرض الصور تدريجياً
adapter.submitList(bitmaps)
}
}
}عندما تقوم بتحميل صورة في main thread، يحدث التالي: ١) النظام يرسل طلب HTTP إلى السيرفر، ٢) السيرفر يستجيب ويرسل البيانات، ٣) البيانات تُحول إلى Bitmap في الذاكرة، ٤) النظام يعيد رسم الـ View. كل هذه الخطوات تحدث في نفس الـ Thread الذي يرسم الواجهة، مما يسبب تجمداً لأن الـ Event Loop لا يستطيع معالجة لمسات المستخدم أثناء انتظار استجابة الشبكة. في الحل الصحيح، الخطوات ١-٣ تحدث في background thread، بينما الخطوة ٤ فقط تحدث في main thread، مما يسمح للواجهة بالاستمرار في الاستجابة.
في عام ٢٠١٩، عملت على تطبيق توصيل طعام يستخدم Redux لتخزين كل شيء، من بيانات المستخدم إلى حالة الطلبات. بعد شهرين من الإطلاق، بدأ التطبيق يبطئ بشكل ملحوظ. عند فحص الأداء، اكتشفت أن كل ضغطة زر تسبب في إعادة رسم ٨٠٪ من المكونات، حتى تلك التي لا تتعلق بالبيانات المتغيرة. السبب؟ Redux يعيد رسم جميع المكونات المتصلة به عند أي تغيير في الـ Store، حتى لو كان التغيير في جزء صغير من البيانات.
الحل ليس التخلي عن Redux تماماً، بل استخدامه بحكمة. مثلاً، في تطبيق إنستاجرام، لا يستخدمون Redux لتخزين الصور التي يتم تحميلها، بل يستخدمون ذاكرة مؤقتة محلية مع مكتبة مثل React Query أو SWR. هذه المكتبات تخزن البيانات مؤقتاً وتدير الـ State بشكل أكثر كفاءة، مع ميزة إعادة المحاولة التلقائية عند فشل الشبكة. في أحد مشاريعي، استبدلت Redux بـ React Query لتخزين بيانات المستخدم، مما قلل من وقت تحميل الصفحة بنسبة ٦٠٪.
// مثال خاطئ: استخدام Redux لتخزين بيانات مؤقتة
// في ملف actions.js
const fetchUserData = (userId) => async (dispatch) => {
const resp await fetch(`/api/users/${userId}`);
const data = await response.json();
dispatch({ type: 'SET_USER_DATA', payload: data }); // هذا يسبب إعادة رسم جميع المكونات
};
// الحل الصحيح: استخدام React Query
import { useQuery } from 'react-query';
const fetchUserData = async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
};
// في المكون
const { data, isLoading } = useQuery(['user', userId], () => fetchUserData(userId), {
staleTime: 5 * 60 * 1000, // البيانات صالحة لمدة ٥ دقائق
cacheTime: 10 * 60 * 1000, // البيانات تبقى في الذاكرة لمدة ١٠ دقائق
});استخدم Redux فقط للبيانات التي تحتاج إلى مشاركة بين عدة شاشات أو تحتاج إلى تتبع تاريخ التغييرات (مثل سلة التسوق). بالنسبة للبيانات المحلية التي لا تحتاج إلى مشاركة، استخدم useState أو useReducer. بالنسبة للبيانات التي تأتي من الشبكة، استخدم مكتبات مثل React Query أو SWR. في تطبيق تيك توك، مثلاً، يستخدمون Redux لتخزين حالة المستخدم (معلومات الحساب، الإعدادات)، بينما يستخدمون ذاكرة مؤقتة محلية لتخزين مقاطع الفيديو التي تم تحميلها مؤخراً.
في أحد تطبيقاتي، لاحظت أن استهلاك الذاكرة يزيد بمقدار ٥٠ ميجابايت كل مرة يفتح المستخدم شاشة معينة. بعد التحقيق، اكتشفت أن المشكلة كانت في Event Listeners التي لم أقم بإزالتها عند مغادرة الشاشة. في أندرويد، يحدث هذا عندما تستخدم addOnScrollListener في RecyclerView دون إزالة المستمع في onDestroy. في آي أو إس، يحدث عندما تستخدمclosures دون استخدام weak self. النتيجة؟ التطبيق يصبح أبطأ تدريجياً حتى يتم قتله من قبل النظام.
الحل هو استخدام أدوات تحليل الذاكرة مثل Android Profiler أو Xcode Instruments. في أندرويد، يمكنك استخدام LeakCanary، وهي مكتبة تكشف تلقائياً عن الـ Memory Leaks وتظهر لك بالضبط أين حدثت. في أحد مشاريعي، ساعدتني LeakCanary في اكتشاف leak في WebView حيث كنت أنسى استدعاء destroy() عند مغادرة الشاشة، مما تسبب في تسرب ٢٠ ميجابايت من الذاكرة في كل مرة يتم فيها فتح صفحة ويب.
// مثال على Memory Leak في أندرويد
public class MainActivity extends AppCompatActivity {
private RecyclerView recyclerView;
private MyAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
recyclerView = findViewById(R.id.recyclerView);
adapter = new MyAdapter();
recyclerView.setAdapter(adapter);
// هذا سيسبب Memory Leak إذا لم يتم إزالته في onDestroy
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
// منطق التمرير
}
});
}
// الحل: إزالة المستمع في onDestroy
@Override
protected void onDestroy() {
super.onDestroy();
recyclerView.clearOnScrollListeners(); // إزالة جميع المستمعين
}
}أولاً، استخدم أدوات التحليل المدمجة في بيئات التطوير. في أندرويد ستوديو، افتح Android Profiler واختبر التطبيق في سيناريوهات مختلفة. ابحث عن زيادة مستمرة في الذاكرة عند التنقل بين الشاشات. في Xcode، استخدم Instruments مع أداة Allocations. ثانياً، اختبر التطبيق على أجهزة حقيقية وليست المحاكيات فقط، لأن سلوك الذاكرة يختلف بين الأجهزة. ثالثاً، استخدم مكتبات مثل LeakCanary في أندرويد أو MLeaksFinder في آي أو إس، التي تراقب التطبيق تلقائياً وتخبرك عند حدوث leak.
في عام ٢٠٢١، عملت على تطبيق لعرض العقارات يستخدم صور عالية الدقة لكل عقار. بعد الإطلاق، لاحظنا أن التطبيق يستهلك الكثير من البيانات ويتجمد عند التمرير. المشكلة؟ كنا نحمل صور بحجم ٥ ميجابايت لكل عقار ونعرضها في قائمة تحتوي على ٢٠ عقار. النتيجة: استهلاك بيانات زائد وتجمد الواجهة بسبب معالجة الصور الكبيرة. الحل؟ استخدمنا مكتبة Glide في أندرويد وSDWebImage في آي أو إس لتحميل الصور بحجم مناسب للشاشة فقط، مع ضغط تلقائي للصورة قبل التحميل.
الحقيقة هي أن معظم المستخدمين لا يحتاجون إلى صور بدقة ٤K في تطبيقات الموبايل. استخدم تقنيات مثل Responsive Images حيث ترسل صوراً بحجم مناسب لشاشة الجهاز. مثلاً، إذا كان الجهاز يحتوي على شاشة بدقة ١٠٨٠p، فلا ترسل صورة بدقة ٤K. استخدم أيضاً تنسيقات حديثة مثل WebP التي توفر ضغطاً أفضل من JPEG وPNG. في تطبيق تيك توك، يستخدمون تقنيات مثل Adaptive Bitrate Streaming للفيديوهات، حيث يتم ضبط جودة الفيديو تلقائياً بناءً على سرعة الإنترنت.
// مثال على تحميل الصور بكفاءة في آي أو إس باستخدام SDWebImage
import SDWebImage
class PropertyCell: UICollectionViewCell {
@IBOutlet weak var propertyImageView: UIImageView!
func configure(with imageUrl: URL) {
// SDWebImage يقوم بتحميل الصورة بحجم مناسب للشاشة مع ضغط تلقائي
propertyImageView.sd_setImage(
with: imageUrl,
placeholderImage: UIImage(named: "placeholder"),
options: [.progressiveLoad, .scaleDownLargeImages], // تحميل تدريجي وتخفيض حجم الصور الكبيرة
completed: nil
)
}
override func prepareForReuse() {
super.prepareForReuse()
propertyImageView.sd_cancelCurrentImageLoad() // إلغاء التحميل عند إعادة استخدام الخلية
}
}في أحد تطبيقاتي، افترضت أن المستخدمين سيكون لديهم اتصال إنترنت دائم. بعد الإطلاق، تلقيت شكاوى من مستخدمين في مناطق ذات تغطية ضعيفة. المشكلة؟ التطبيق كان يتجمد عند فقدان الاتصال بدلاً من عرض البيانات المخزنة مؤقتاً. الحل؟ استخدمت Room في أندرويد وCore Data في آي أو إس لتخزين البيانات محلياً، مع مزامنة تلقائية عند استعادة الاتصال. النتيجة: تحسن رضا المستخدمين بنسبة ٤٠٪، خاصة في المناطق الريفية.
تجاهل الـ Offline Experience هو خطأ شائع في التطبيقات التي تعتمد على الشبكة. المستخدمون يتوقعون أن يعمل التطبيق حتى بدون اتصال، خاصة في تطبيقات مثل الخرائط والتواصل الاجتماعي. استخدم تقنيات مثل Caching وLocal Storage وBackground Sync. في تطبيق خرائط جوجل، مثلاً، يمكنك تنزيل خرائط لمنطقة معينة للاستخدام بدون اتصال. في تطبيقات التواصل الاجتماعي، يمكنك تخزين الرسائل الأخيرة محلياً وعرضها عند فقدان الاتصال.
// مثال على استخدام WorkManager في أندرويد للمزامنة في الخلفية
class SyncWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
override fun doWork(): Result {
return try {
// جلب البيانات من الشبكة
val resp apiService.fetchUpdates()
// حفظ البيانات في قاعدة البيانات المحلية
database.updateData(response)
Result.success()
} catch (e: Exception) {
// إذا فشل، حاول مرة أخرى لاحقاً
Result.retry()
}
}
}
// جدولة المزامنة كل ساعة
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueue(syncRequest)أولاً، تخزين البيانات المحلية: استخدم Room في أندرويد أو Core Data في آي أو إس لتخزين البيانات المهمة محلياً. ثانياً، إدارة حالة الاتصال: استخدم ConnectivityManager في أندرويد أو NWPathMonitor في آي أو إس لمراقبة حالة الاتصال وعرض رسائل مناسبة للمستخدم. ثالثاً، المزامنة الذكية: استخدم WorkManager في أندرويد أو BackgroundTasks في آي أو إس لمزامنة البيانات في الخلفية عندما يتوفر الاتصال. رابعاً، تجربة المستخدم: صمّم واجهة المستخدم لتظهر البيانات المخزنة مؤقتاً مع رسالة توضح أن المستخدم يعمل بدون اتصال.
في أحد المشاريع، اختبرت التطبيق فقط على محاكي أندرويد سريع وجهاز آي أو إس حديث. بعد الإطلاق، تلقيت شكاوى من مستخدمين يستخدمون أجهزة قديمة مثل Samsung Galaxy J7. المشكلة؟ التطبيق كان بطيئاً جداً على هذه الأجهزة بسبب استخدام مكتبات ثقيلة مثل Lottie للرسوم المتحركة. الحل؟ اختبرت التطبيق على مجموعة متنوعة من الأجهزة، بما في ذلك الأجهزة ذات المواصفات المنخفضة، وقمت بتحسين الأداء باستخدام تقنيات مثل Code Splitting وLazy Loading.
المحاكيات والأجهزة الحديثة لا تعكس الواقع. المستخدمون يستخدمون مجموعة متنوعة من الأجهزة، بما في ذلك تلك التي عمرها ٥ سنوات. اختبر التطبيق على أجهزة حقيقية ذات مواصفات مختلفة، خاصة تلك التي تستهدفها السوق. استخدم أدوات مثل Firebase Test Lab لاختبار التطبيق على مجموعة واسعة من الأجهزة. في أحد مشاريعي، اكتشفت أن التطبيق يستغرق ١٥ ثانية لفتحه على جهاز Samsung Galaxy A10، بينما يستغرق ثانيتين فقط على iPhone 13. بعد التحقيق، اكتشفت أن المشكلة كانت في مكتبة تحليل البيانات التي كانت تحمّل جميع البيانات عند بدء التشغيل. الحل؟ استخدمت Code Splitting لتحميل المكتبة فقط عند الحاجة إليها.
// مثال على Code Splitting في ريأكت نيتف باستخدام React.lazy
import React, { Suspense, lazy } from 'react';
// تحميل المكون فقط عند الحاجة إليه
const AnalyticsDashboard = lazy(() => import('./AnalyticsDashboard'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<AnalyticsDashboard />
</Suspense>
);
}بعد بناء أكثر من ٥٠ تطبيق موبايل، تعلمت أن النجاح لا يتعلق بالكود الجميل فقط، بل بالقرارات الصغيرة التي تتخذها يومياً. إليك ما يجب أن تفعله من اليوم الأول: ١) افهم الـ Threading Model في النظام الذي تعمل عليه، ولا تضع أي عملية ثقيلة في main thread. ٢) استخدم أدوات إدارة الـ State بحكمة، ولا تخزن كل شيء في Redux أو Context API. ٣) راقب الذاكرة باستمرار، واستخدم أدوات مثل LeakCanary لاكتشاف الـ Memory Leaks مبكراً. ٤) قم بضغط الصور والفيديوهات، واستخدم تنسيقات حديثة مثل WebP وAVIF. ٥) صمّم التطبيق للعمل بدون اتصال، واستخدم تقنيات مثل Caching وBackground Sync. ٦) اختبر التطبيق على أجهزة حقيقية، ولا تعتمد فقط على المحاكيات.
الأخطاء التي تحدثت عنها ليست مجرد bugs، بل هي قرارات تصميمية ستؤثر على أداء التطبيق وسهولة صيانته على المدى الطويل. كل خطأ من هذه الأخطاء يمكن تجنبه بسهولة إذا فهمت كيف يعمل النظام تحت الغطاء. لا تنتظر حتى تواجه المشكلة في الإنتاج لتصحيحها — ابدأ بالتفكير في هذه الأمور من اليوم الأول، وستوفر على نفسك وعلى فريقك الكثير من الوقت والجهد.