نشت حافظه در اندروید! هر آنچه لازم است بدانید

نویسنده : سید ایوب کوکبی ۱۴ بهمن ۱۳۹۷

ساخت یک اپلیکیشن اندروید چندان سخت نیست ولی ساخت برنامه‌ای با مصرف حافظۀ بهینه دشوار است. اغلب توسعه‌دهندگان از جمله خودم بیشتر تمرکزمان را روی اضافه کردن قابلیت‌های جدید و بهبود واسط کاربری برنامه صرف می‌کنیم چون کاربران نیز به ظاهر کار بیش از باطنش اهمیت می‌دهند. در بهترین حالت، بسیاری از توسعه‌دهندگان بهینه‌سازی داخلی را تا تا زمانی که صدای مشتری درنیامده یا برنامه با مشکل جدی روبرو نشده پشت گوش می‌اندازند. اگر بخواهم خودمانی بگویم. بهینه‌سازی داخلی و مقابله با مشکلات نشت حافظه (Memory Leak) اولویت آخر توسعه‌دهندگان است در حالی که بای اولویت اولشان باشد!

البته به مرور زمان و با افزایش تعداد کاربران برنامه، سطح انتظارات نیز به نسبت بالاتر رفته و با افزایش تجربه و سربار زمانی رفع مشکلاتی که یکی پس از دیگری ظاهر می‌شوند، کم‌کم به این نتیجه می‌رسید که صرف زمان روی پرفرمنس برنامه کار چندان عبثی هم نیست. نشت حافظه موضوعی است که اغلب توسعه‌دهندگان از آن بیزارند. پیدا کردن این مشکل کاری بس دشوار، زمان‌بر، خسته‌کننده و متأسفانه ضروری است اما خوشبختانه وقتی آستین بالا می‌زنید و به صورت جدی وارد این عرصه می‌شوید، سرعت یافتن نشتی حافظه بیشتر خواهد شد و به خاطر تجربیات کسب شده، همچون گذشته کاری خسته‌کننده نخواهد بود.

در این مقاله، روش ساخت برنامه‌های کارآمد، با کیفیت مطلوب و پرفرمنس بالا را به زبان ساده بیان می‌کنم. باشد که ساخت چنین برنامه‌هایی حتی برای کاربران مبتدی که تازه شروع به کدنویسی کرده‌اند نیز مقدور باشد.

Garbage Collector دوست شماست ولی نه همیشه!

جاوا زبان جذابی است. در اندروید (تا جایی که می‌دانم) کدها به زبان‌هایی مثل C یا ++C نوشته نمی‌شوند که مسئولیت مدیریت و آزادسازی حافظه بر دوش توسعه‌دهنده است. خوشبختانه زبان جاوا با مکانیزم garbage collector تا حدود زیادی خیال توسعه‌دهندگان را از این بابت راحت کرده است. بله GC به صورت خودکار بخش‌هایی از حافظه را که اشغال شده و دیگر نیازی به آن‌ها نیست آزاد می‌کند به شرطی که اشتباهات کدنویسی ما این مکانیزم را سرگردان نکند! در واقع GC دستاورد ارزشمند جاوا بوده و کارش را به درستی انجام می‌دهد، اشکال از ماست که با سهل‌انگاری در کدنویسی زمینه را برای عملکرد ناقص آن فراهم می‌کنیم.

کمی درباره GC

قبل از وارد شدن به جزئیات، ابتدا لازم است کمی با عملکرد Garbage Collector آشنا شوید. مفهوم کلی ساده است ولی چیزی که در پس‌زمینه رخ می‌دهد ممکن است پیچیده باشد. اما نگران نباشید، ما بیشتر روی قسمت‌های ساده تمرکز می‌کنیم.

ارتباط GC با نشت حافظه در اندروید

حتی برنامه‌های اندروید (یا جاوا) نقطه‌ای برای شروع اجرا و نمونه‌سازی اشیاء و فراخوانی متدها دارند. این نقطه شروع را root یا ریشه درخت حافظه (Memory Tree) می‌نامیم. تعدادی از آبجکت‌ها مستقیماً به ریشه ارجاع می‌دهند و تعدادی دیگر نیز به نوبۀ خود به این آبجکت‌ها رفرنس می‌دهند. بنابراین همانطور که در تصویر می‌بینید، زنجیره‌ای از رفرنس‌ها، درخت حافظه را تشکیل می‌دهند. بنابراین، GC کاری که می‌کند، بررسی این ارجاعات از ریشه به بالاست. GC به هیچ یک از ارجاع‌های زنده‌ (چه مستقیم چه غیرمستقیم) کاری ندارد. دستِ آخر، تعدادی آبجکت در ظرف حافظه باقی می‌مانند که پاکسازی‌شان بر GC حلال است 🙂 در واقع این‌ها همان اشیاء مرده و به تعبیر جاوا Garbage (زباله)هایی هستند که باید پاکسازی شوند. تا الان همه‌چیز مرتب و زیبا به نظر می‌رسد. اما کمی بیشتر وارد قضیه شویم تا قسمت‌های جالب‌ترش را ببینیم.

برای کسب اطلاعات بیشتر دربارۀ GC توصیه می‌کنم به این ویدیو در یوتیوب یا این مطلب نگاهی بیندازید.

خب، بالاخره این نشست حافظه چه هست؟

تا الان، چشم‌اندازی از عملکرد Garbage Collector و روند مدیریت حافظه در اندروید به دست آوردید. اکنون بپردازیم به اصل ماجرا: نشست حافظه.

زمانی می‌گوییم نشت حافظه (memory leak) رخ داده یا حافظه نشت کرده که تعدادی از اشیاء بعد از انجام وظیفه و در نبود هیچ ارجاعی به آن‌ها همچنان در حافظه جاخوش کرده‌ و از بین نرفته‌اند.

هر آبجکتی طول عمری دارد که پس از آن باید با حافظه خداحافظی کرده و جایش را به بقیه بدهد اما تا زمانی که سایر اشیاء به صورت مستقیم یا غیرمستقیم به این آبجکت‌ها ارجاعی داشته باشند GC حق جمع‌آوری و حذف آن‌ها را ندارد و این سرآغاز مشکلات است. البته خبر خوب اینکه، لزومی به نگرانی دربارۀ همۀ نشتی‌های حافظه نیست. در واقع همۀ آن‌ها به برنامه آسیب نمی‌زنند. تعدادی از نشتی‌ها بسیار جزئی (در حد مصرف چند کیلوبایت) بوده که در داخل خود فریم‌ورک اندروید نیز وجود دارد. بله درست شنیدید Android SDK محصول انسان بوده و از شر memory leakها در امان نیست. این نشتی‌ها را معمولاً نمی‌توان یا نیازی نیست برطرف کرد چون واقعاً چزئی هستند. در واقع تاثیرشان روی پرفرمنس برنامه آنقدر جزئی است که ارزش پیگیری ندارند. اما تعدادی از نشتی‌ها هستند که اگر به حال خود رهایشان کنیم باعث کرش کردن برنامه خواهند شد. روی سخن من این دسته از نشتی‌هاست.

بخوانید  انتشار برنامه اندروید مثل یک حرفه‌ای

واقعاً چرا لازم است تا این حد به نشت حافظه اهمیت دهیم؟

هیچ کس دوست ندارد اپلیکیشنی کند، لگ‌دار، پرمصرف و با کرش‌های پیاپی نصب کند. این موضوع به شدت روی تجربه کاربری تاثیر گذاشته و در صورتی که برای مدتی طولانی این مشکلات وجود داشته باشند و سازنده اقدام به رفع آن‌ها نکند بعید نیست که محصول شما را حذف کرده و سراغ رقبایتان بروند.

همینطور که کاربران با برنامه شما کار می‌کنند، مصرف حافظه heap هم بیشتر می‌شود. در چنین شرایطی اگر برنامه نشتی حافظه داشته باشد، GC قادر نیست حافظۀ بلااستفادهۀ heap را آزاد کند. بنابراین heap همچنان به پر شدن ادامه داده تا جایی که دیگر ظرفیت کافی ندارد. اینجاست که برنامه با خطای OutOfMemoryError بسته می‌شود.

این را هم بدانید که Garbage Collection فرایند سنگینی است و هرچه GC کمتر اجرا شود، به نفع برنامه شماست.

مادامی که کاربر از برنامه شما استفاده می‌کند، حافظۀ Heap بیشتر و بیشتر مصرف می‌شود. در حالت عادی جاوا با احضار GCهای کوچکی اشیاء بلااستفاده را فوراً از حافظه پاکسازی می‌کند و از آنجایی که این GCها به صورت همزمان روی نخ‌های جدا اجرا می‌شوند، به هیچ عنوان سرعت برنامه را کاهش نمی‌دهند. نهایت تاخیری که در برنامه ایجاد کنند ۲ الی ۵ میلی‌ثانیه است. اما اگر نشتی‌های زیادی در برنامه وجود داشته باشد، GCهای کوچک قادر به آزاد کردن حافظه نبوده و GCهای بزرگتری وارد عمل شده که منجر به تاخیرهای طولانی‌تری در حد ۵۰ الی ۱۰۰ میلی‌ثانیه می‌شوند. چنین تاخیری به راحتی می‌تواند در برنامه لگ و ناپایداری ایجاد کند. 

پس دیدید که نشت حافظه موضوعی جدی بوده و چطور می‌تواند روی تجربۀ کاربران شما تاثیر بگذارد. و دقیقاً به همین دلیل رفع Memory Leakها، امری حیاتی است.

نشت حافظه را چگونه پیدا کنیم؟

احتمالاً تا الان قانع شده‌اید که رفع نشتی حافظه موضوع مهمی است. خب چطور این نشتی‌ها را پیدا کنیم؟ خوشبختانه اندروید استودیو ابزار بسیار خوب و قدرتمندی برای این کار دارد؛ اسمش Monitors است. به جزء مانیتور حافظه، مانیتورهای اختصاصی دیگری نیز برای شبکه، پردازنده، GPU وجود دارد (اطلاعات بیشتر).

هنگام دیباگ کردن برنامه، بایستی همواره یک چشمتان روی مانیتور حافظه باشد. اولین نشانه از نشتی حافظه زمانی پدیدار می‌شود که در زمان اجرای برنامه، گراف مصرفی به صورت مداوم بالا باشد و پایین هم نیاید حتی زمانی که برنامه را در بک‌گراند قرار می‌دهید. این یعنی خبری هست!

زمانی که لازم است ریشه اصلی مشکل را شناسایی کنید، ابزار Allocation Tracker به درد می‌خورد چون سهم هر آبجکت از حافظه را با نمودار و درصد نشان می‌دهد که با نگاهی گذرا می‌توان مقصر اصلی را شناسایی کرد. ولی این اطلاعات آنگاه که نیازمند یک دامپ کامل از حافظه Heap در لحظه خاصی از اجرای برنامه هستید کفایت نمی‌کند. انجامش غیرممکن نیست ولی قطعاً حوصله و زمان زیادی می‌طلبد.

ما مهندسین معمولاً میل به تنبلی داریم. یعنی هر جایی که ابزاری وجود نداشت خودمان دست به کار شده و ابزار کارمان را می‌سازیم. سخاوتمند هم هستیم وآن را در اختیار دیگران قرار می‌دهیم. نمونه‌اش همین LeakCanary که برای رفع کمبودهای ابزار تحلیل حافظه در اندروید استودیو ساخته شده است. این کتابخانه در کنار برنامه اجرا شده و هر زمانی که لازم باشد دامپی از حافظه تهیه می‌کند. نشتی‌های مشکوک را شناسایی و در قالب یک Stack Trace واضح و تمییز، ریشه نشتی را در نوتیفیکیشن اعلام می‌کند. LackCanary یافتن نشتی حافظه را بسیار سهولت بخشیده و لازم است همینجا از Py (از Square) بابت ساخت چنین کتابخانه ارزشمند و مفیدی تشکر کنم. برای کسب اطلاعات بیشتر از و چگونگی استفاده موثر از کتابخانه به اینجا مراجعه کنید.

بخوانید  ساخت یک اپلیکیشن موبایل چقدر هزینه می‌برد؟

سناریوهای رایجِ نشت حافظه و راه تعمیر آن

نشت حافظه در شرایط مختلفی می‌تواند بروز کند اما با توجه به تجربه چندساله‌ام در کدنویسی اندروید تعدادی از سناریوها که توسعه‌دهندگان به صورت روزمره با آن سروکار دارند بیش از بقیه موارد می‌تواند به این مشکل منجر شود که اطلاع از آن‌ها می‌تواند سریعاً مشکلات نشتی حافظه را رفع کرده یا حتی اجازه وقوع ندهد.

Listenerهای رجیستر نشده

در موقعیت‌های مختلفی داخل اکتیویتی یا فرگمنت لازم است که Listenerی را رجیستر کنید، اما مشکل زمانی بروز می‌کند که یادتان برود آنرجیسترش کنید. اگر آدم بدشانسی باشید همین موضوع می‌تواند به نشتی حافظه بزرگی منجر شود. معمولا بعد از رجیستر کردن یک لیستنر حتماً بایستی ان را آنرجیستر کنید. فکر کنم با مثال بهتر بفهمید. فرض کنید می‌خواهید موقعیت جغرافیایی بروز شده را دریافت کنید. برای این کار کافی است تا لیستنری را به سرویس سیستمی LocationManager متصل کنید.

private void registerLocationUpdates(){
mManager = (LocationManager) getSystemService(LOCATION_SERVICE);
mManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,
            TimeUnit.MINUTES.toMillis(1), 
            ۱۰۰, 
            this);
}

شما اینترفیس Listener را داخل اکتیویتی پیاده‌سازی کرده‌اید بنابراین LocationManager ارجاعی به آن نگه‌داری می‌کند. اکنون وقتی نوبت مرگ اکتیویتی فرا می‌رسد، اندروید متد ()onDestroy را فرخوانی کرده ولی GC قادر به حذف آبجکت ایجاد شده از آن اکتیویتی نیست چرا که LocationManager هنوز ارجاعی به آن نگه‌داری می‌کند.

راهکار حل این مشکل بسیار آسان است. کافی است داخل متد ()onDestroy آن Listener را Unregister کنید. این کاری است که اغلب ما فراموش می‌کنیم و حتی نسبت به آن بی‌اطلاع هستیم.

@Override
public void onDestroy() {
    super.onDestroy();
    if (mManager != null) {
        mManager.removeUpdates(this);
    }
}

کلاس‌های تودرتو

Inner Class بحثی رایج در جاوا است و به خاطر سادگی‌اش بارها در وظایف روتین خود از آن استفاده می‌کنیم. اما استفاده نادرست از آن می‌تواند نشتی حافظه بالقوه‌ای در پی داشته باشد. مثال:

public class BadActivity extends Activity {
    private TextView mMessageView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.layout_bad_activity);
        mMessageView = (TextView) findViewById(R.id.messageView);
        new LongRunningTask().execute();
    }
    private class LongRunningTask extends AsyncTask<Void, Void, String> {
        @Override
        protected String doInBackground(Void... params) {
            return "Am finally done!";
        }
        @Override
        protected void onPostExecute(String result) {
            mMessageView.setText(result);
        }
    }
}

این یک اکتیویتی بسیار ساده بوده که آغازگر تسکی طولانی در ترد زمینه است (شاید یک کوئری سنگین روی دیتابیس یا ارتباط کند با شبکه.) بعد از اتمام تسک، نتیجه داخل یک تکست‌ویو نمایش داده می‌شود. همه چیز خوب به نظر می‌رسد نه؟ قطعاً خیر. مشکل اینجاست که کلاس غیر استاتیک داخلی، ارجاعی ضمنی به کلاس خارجی نگه می‌دارد. (که خود اکتیویتی است.) خب حالا اگر صفحه را بچرخانیم و تسک ما بیشتر از عمر اکتیویتی کار داشته باشد، GC نمی‌تواند بقایای اکتیویتی را از حافظه پاکسازی کند. می‌بینید، یک اشتباه کوچک چقدر راحت به نشت حافظه تبدیل می‌شود.

چاره کار آسان است. به کد نگاه کنید خودتان متوجه می‌شوید:

public class GoodActivity extends Activity {
    private AsyncTask mLongRunningTask;
    private TextView mMessageView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.layout_good_activity);
        mMessageView = (TextView) findViewById(R.id.messageView);
        mLongRunningTask = new LongRunningTask(mMessageView).execute();
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
        mLongRunningTask.cancel(true);
    }
    private static class LongRunningTask extends AsyncTask<Void, Void, String> {
        private final WeakReference<TextView> messageViewReference;
        public LongRunningTask(TextView messageView) {
            this.messageViewReference = new WeakReference<>(messageView);
        }
        @Override
        protected String doInBackground(Void... params) {
            String message = null;
            if (!isCancelled()) {
                message = "I am finally done!";
            }
            return message;
        }
        @Override
        protected void onPostExecute(String result) {
            TextView view = messageViewReference.get();
            if (view != null) {
                view.setText(result);
            }
        }
    }
}

همانطور که می‌بینید inner class غیر استاتیک را به یک inner استاتیک تبدیل کرده‌ایم. دلیلش این است که کلاس‌های داخلی استاتیک هیچگاه ارجاع ضمنی به کلاس بیرونی نگه‌داری نمی‌کنند. خب حالا به متغیرهای غیراستاتیک کلاس بیرونی دسترسی نداریم. چه کنیم؟ ارجاع به اشیاء لازم را از طریق سازنده، پاس می‌دهیم. اکیداً توصیه می‌کنم که این اشیاء را WeakReference کنید تا جلوی نشت حافظه را بگیرید. برای کسب اطلاعات بیشتر دربارۀ انواع ارجاع قابل استفاده در جاوا و روش استفاده از آن‌ها جهت جلوگیری از نشت حافظه، این مقاله را بخوانید.

بخوانید  درس‌هایی از 21 برنامه‌ی اندرویدی

کلاس‌های Anonymous

این کلاس‌ها به خاطر اختصار در کدنویسی به کرات در کدهای اندروید استفاده می‌شوند. اما به تجربه می‌گویم، بخش عمده‌ای از نشتی حافظه زیر سر همین Anonymous Class های جذاب است. این کلاس‌ها چیزی نیستند جزء inner classهای غیراستاتیک که پیش‌تر سهمشان در بروز نشتی حافظه را خواندید. احتمالاً تعداد زیادی از این کلاس‌های بی‌نام‌و‌نشان را در جای جای کدهای خود استفاده کرده‌اید. اما استفاده نادرست از آن‌ها می‌تواند به نشتی حافظه منجر شود.

public class MoviesActivity extends Activity {
    private TextView mNoOfMoviesThisWeek;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.layout_movies_activity);
        mNoOfMoviesThisWeek = (TextView) findViewById(R.id.no_of_movies_text_view);
        MoviesRepository repository = ((MoviesApp) getApplication()).getRepository();
        repository.getMoviesThisWeek()
                .enqueue(new Callback<List<Movie>>() {
                    
                    @Override
                    public void onResponse(Call<List<Movie>> call,
                                           Response<List<Movie>> response) {
                        int numberOfMovies = response.body().size();
                        mNoOfMoviesThisWeek.setText("No of movies this week: " + String.valueOf(numberOfMovies));
                    }
                    @Override
                    public void onFailure(Call<List<Movie>> call, Throwable t) {
                        // Oops.
                    }
                });
    }
}

در اینجا از کتابخانۀ مشهور Retrofit برای ارتباط با شبکه و نمایش نتایج در یک تکست‌ویو استفاده کرده‌ایم. کاملاً واضح است که Callable object ارجاعی به اکتیویتی نگه‌داری می‌کند. اکنون اگر این فرخوانی شبکه در یک اتصال اینترنتی کند گرفتار شود و قبل از اتمام کارش، اکتیوتی چرخانده شود یا به دلایل دیگری destryed شود، کل اکتیویتی از چشمان GC پنهان خواهد ماند. توصیه می‌کنم به جای استفاده از کلاس‌های Anonymous هر جایی لازم شد از اینرکلس‌های استاتیک استفاده کنید. البته حرف من معنی‌اش این است که جایی که می‌توانید به صورت ایمن و درست از Anonymous Classها استفاده کنید اجتناب نمایید.

Bitmaps

تصاویر موجود در برنامه چیزی جزء اشیاء Bitmap که داده‌های پیکسلی آن تصاویر را نگه‌داری می‌کنند نیستند. این اشیاء غالباً سنگین بوده و استفاده نادرست از آن‌ها ریسک نشت حافظه در پی دارد. (خطای OutOfMemoryError.) معمولاً حافظه مورد نیاز ریسورسهای برنامه توسط فریم‌ورک اندروید به صورت خودکار مدیریت می‌شود ولی اگر به دلایلی ناگزیر به مدیریت دستی این حافظه بودید فرمواش نکنید که به محض استفاده از حافظه، با متد ()recycle آزادش کنید تا برایتان مشکل ایجاد نکند. همچنین لازم است مدیریت صحیح بیت‌مپ‌ها، روش لود کردن بیت‌مپ‌های سنگین با Scale Down کردنشان، استفاده از bitmap caching و pooling را یاد بگیرید. برای کسب اطلاعات بیشتر در این زمینه مستندات رسمی گوگل را پیشنهاد می‌کنم.

Contexts

دلیل عمده دیگر نشت حافظه، استفاده نادرست از context است. context یک کلاس Abstract بوده که عملکرد بسیاری از کلاس‌های اندروید (مثل Activity, Application, Service و …) وابسته به آن است؛ به همین همه این کلاس‌ها Context را extend کرده‌اند. در واقع این کلاس نقشی کلیدی در چارچوب اندروید بازی می‌کند. برای کسب اطلاعات بیشتر دربارۀ Context به این مطلب از اسکارپ مراجعه کنید.

اما بین کانتکست‌های مختلف تفاوت‌هایی وجود دارد. بسیار مهم است که فرق بین یک Context در سطح اکتیویتی را با Contextای که در سطح Application است بدانید و از هر یک کی و کجا استفاده کنید. استفاده از کانتکستd نادرست در جایی نامناسب می‌تواند ارجاع به کل اکتیوتی را نگه دارد که منجر به memory leak خواهد شد. این مقاله اطلاعات خوبی برای شروع فراهم کرده است.

نتیجه‌گیری

اکنون بایستی بدانید که GC چگونه کار می‌کند؛ منظور از نشت حافظه چیست؛ چه تاثیری روd برنامه شما دارد؛ چگونه شناسایی و با آن برخورد کنید. پس جای بهانه نیست و از همین امروز روی نوشتن برنامه‌هایی باکیفیت، پرفرمنس بالا و عملکرد مناسب تمرکز کنید. شناسایی و رفع نشتی حافظه تنها منفعتش بهبود تجربۀ کاربری نبوده بلکه از شما یک توسعه‌دهنده بهتر می‌سازد.

این مطلب ترجمه‌ای بود از مقاله Everything You Need To Know About Memory Leaks In Android Apps که به قلم آقای Aritra Roy نوشته شده است.

سید ایوب کوکبی

نویسنده و مترجم...

0 دیدگاه

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *