چقدر به مشکلات performance برنامه ای که نوشتید آگاه هستید؟ آیا می دانید کدام متد بیش از حد زمان بر است؟ inflate شدن کدام UI بیش از حد انتظاراتان طول می کشد؟ چه قسمت هایی از برنامه Memory leak دارد؟ کجا از ساختار داده مناسب استفاده نکردید؟ کجا با مشکل overdraw روبرو هستید و می توانید آن را بهبود دهید؟ آیا می دانید چرا برنامه لَگ دارد؟ چرا زمانی که برنامه شما اجرا می شود، کلا عملکرد سیستم افت می کند؟
همانطور که مشخص است performance برای برنامه اندروید جنبه های مختلفی دارد. توانایی سخت افزاری گوشی های موبایل نسبت به لپ تاپ یا کامپیوتر بسیار کمتر است و سیستم resource کمتری در اختیار دارد در نتیجه استفاده درست از آن resource محدود، برای داشتن performance مناسب، بسیار مساله ی مهمی است.
با اینکه performance جنبه های مختلفی دارد ولی شاید به شکل کلی بتوان آن را به:
تقسیم کرد. در بخش اول به CPU پرداختیم. در این مقاله تمرکزمان بر روی Memory است و آن را از جنبه های مختلف بررسی می کنیم. ابتدا به سراغ مواردی می رویم که عمومی تر هستند و سپس به سراغ Profiler می رویم تا در یافتن و حل مشکلات از آن کمک بگیرم. قبل از اینکه ادامه دهیم خوب است با کارکرد کلی Memory در اندروید آشنا شویم. این ارائه به شکل مختصر و مفید به این موضوع پرداخته است. پیشنهاد می کنم حتما آن را ببینید.
شاید عنوان عجیب به نظر بیاید ولی می توانیم با انتخاب ساختار داده مناسب به شکل مطلوب تری از حافظه استفاده کنیم.
حجم اپلیکیشن چه ربطی به Memory دارد؟ با اجرا شدن اپلیکیشن، فایل های dex مربوط به آن، برای اجرای برنامه، به Memory منتقل می شوند. در واقع همیشه بخشی از Memory که به برنامه ی شما اختصاص داده می شود توسط کدها، ریسورس ها و ... اشغال شده و بخشی از آن در طول کار کردن با برنامه به صورت داینامیک اختصاص داده می شود.
پس با کم کردن حجم اپلیکیشن در عمل فضای کمتری از Memory اشغال می شود که قطعا performance بهتری را به همراه خواهد داشت. اینکه چطور حجم apk را کم کنیم از بحثمان خارج است ولی ارائه های مختلفی در Google I/O پیرامون این قضیه وجود دارد.
دو موردی که تا اینجا بررسی کردیم، عمومی بودند (البته این چیزی از ارزش ها و اهمیت آن ها کم نمی کند :)). در ادامه بیشتر به مواردی می پردازیم که به شیوه پیاده سازی ما بر می گردند. سعی می کنیم به کمک Memory Profiler مشکل را پیدا کرده و سپس راه حلی برای آن بیابیم.
عملیات GC زمان بر نیست ولی اگر زیاد و بیجا کال شود می تواند تاثیر مشهودی بر عملکرد اپلیکیشن داشته باشد(اینجا توضیح مختصری داده شده است). در این مقاله به الگوریتم GC نمی پردازیم(اگر مایل بودید یکی از الگوریتم ها اینجا توضیح داده شده است) بلکه فقط به طور کلی می خواهیم بدانیم GC کِی شروع به کار می کند.
اندروید به هر برنامه ای که اجرا می شود، فضایی در حافظه اختصاص می دهد که به آن Heap می گویند. طبیعتا این فضا محدود است. زمانی که شما از تمام این فضا استفاده کردید و نیاز به فضای بیشتری داشتید، پیش از آنکه حافظه بیشتری به شما اختصاص داده شود، ابتدا GC شروع به کار می کند. در نتیجه ی عملکرد GC ممکن است فضای مورد نیاز شما خالی شده باشد. در غیر این صورت اندروید فضای بیشتری به شما اختصاص می دهد.
با توجه به این عملکرد، اگر ما فضایی که در اختیار داریم را به سرعت با آبجکت هایی پر کنیم که همگی قابلیت حذف شدن دارند، سبب کال شدن GC به تعداد زیاد در بازه زمانی کم می شویم و طبیعتا این موضوعی است که به دنبال جلوگیری از آن هستیم.
به سراغ یه مثال برویم.
public class MainActivity extends AppCompatActivity { private String numSequence = "" @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); for (int i = 0; i < 50000; i++) { numSequence = numSequence.concat(String.valueOf(i)); } } }
همانطور که در کد بالا مشخص است در یک loop مقدار یک String را آپدیت می شود. به نظر شما این کد مشکلی دارد؟ چه مشکلی؟
ابتدا برنامه را به کمک Profiler اجرا می کنیم.
سپس تب Profiler (از نوار پایین) را باز می کنیم و روی بخش مربوط به Memory کلیک می کنیم.
در مرحله بعد گزینه سوم یعنی Record Java/Kotlin Allocations را انتخاب کرده و روی Record کلیک می کنیم(از بخش اول می دانیم که برای بررسی دقیق جزییات نیاز داریم یک بازه زمانی را Record کنیم). پس از مدتی که مطمئن شدیم متد مربوطه اجرا شده فرآیند را Stop می کنیم.
خروجی مشابه شکل زیر خواهد بود.
هر کدام از آن آیکون های سفیدِ سطل آشغال، نماینده یک عملیات GC هستند!!! با یک loop ساده ما چقدر سیستم را برای پاک کردن حافظه تحت فشار گذاشتیم. اگر اعدادِ پایین عکس را ببینید، برای String، تعداد ۱۷۷۸۷۳ Allocation و ۱۵۴۷۷۵ Deallocation ثبت شده است. یعنی اکثر فضایی که برای String اشغال کردیم به سرعت آزاد شده. حال کد را به شکل زیر تغییر می دهیم.
public class MainActivity extends AppCompatActivity { private String numSequence; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); StringBuilder builder = new StringBuilder(); for (int i = 0; i < 50000; i++) { builder.append(i); } numSequence = builder.toString(); } }
خروجی اینگونه خواهد بود.
همان عملیات، در زمان کمتر و بدون GC!!!
مگر چه تغییری در کد ایجاد شد؟ صرفا در loop به جای String از StringBuilder استفاده شده است. همانطور که می دانید String در جاوا Immutable است. در واقع با هر Contact کردن، یک String جدید ایجاد می شود. و از طرفی String قبلی هم بلا استفاده است. نتیجه همان عملکرد انقلابی GC خواهد بود. اما کلاس StringBuilder در جاوا Mutable است. در نتیجه، با توجه به مسئله، خیلی بهینه تر عمل می کند.
هرگاه صحبت از Memory است، نام Memory Leak می درخشد :) احتمالا تا به حال حداقل یکبار اسمش به گوشتان خورده است. شاید شایع ترین مشکلی باشد که اپلیکیشن های اندروید با آن دست و پنجه نرم می کنند. به کمک ورژن جدید Memory Profiler می توانیم Memory Leak ها را پیدا کنیم. اگر با Memory leak آشنا نیستید شاید این تعریف ویکیپدیا ذهنتان را واضح تر کند.
In computer science, a memory leak is a type of resource leak that occurs when a computer program incorrectly manages memory allocations in a way that memory which is no longer needed is not released. A memory leak may also happen when an object is stored in memory but cannot be accessed by the running code.
باز به سراغ کد می رویم. در ادامه دو Memory Leak ایجاد می کنیم و آن ها را برطرف می کنیم. اینطور هم با مصادیق Memory Leak و هم با Memory Profiler بیشتر آشنا می شویم. پروژه ای که می خواهیم بررسی کنیم دو Activity دارد. Activity اول خالی است و صرفا یک Intent به Activity دوم دارد.
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Intent intent = new Intent(this, SecondActivity.class); startActivity(intent); } }
در Activity دوم هم یک AsyncTask را شروع می کنیم که به شکل Inner Class تعریف شده است. در Async Task چند loop تو در تو، صرفا به عنوان یک عملیات زمان بر، نوشته شده است.
public class SecondActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_second); MyAsyncTask myAsyncTask = new MyAsyncTask(); myAsyncTask.execute(); } public class MyAsyncTask extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... voids) { for (int b = 0; b < 100000; b++) { for (int a = 0; a < 100000; a++) { for (int i = 0; i < 100000; i++) { StringBuilder stringBuilder = new StringBuilder(); for (int j = 0; j < 100; j++) { stringBuilder.append(j); } } } } return null; } } }
حال برنامه را به کمک Profiler اجرا می کنیم. پس از اجرای برنامه، طبق کد، از MainActivity به SecondActivity خواهیم رفت و AsyncTask شروع به کار می کند. حال دکمه back را می زنیم که به MainActivity برگردیم.
در این مرحله Profiler را باز می کنیم، روی بخش Memory کلیک می کنیم، گزینه اول یعنی Capture Heap Dump را انتخاب کرده و در نهایت روی Record را آغاز می کنیم.
این گزینه، وضعیت فعلی Heap را به ما نشان می دهد. پیش از آنکه سراغ بررسی آن برویم شما انتظار دارید SecondActivity در Heap وجود داشته باشد یا خیر؟ چرا؟ یک بار سناریو را مرور کنید.
نتایج Profiler چه چیزی به ما نشان می گویند؟
اوپس! Memory Leak !
چرا؟ مگر پس از زدن back در سناریو، SecondActivity بسته نشده است؟ پس چرا در حافظه باقی مانده و Memory Leak ایجاد کرده؟ جواب این سوال در طراحی ما نهفته است. ما AsyncTask را به شکل Inner Class تعریف کردیم. این سبب شده بین SecondActivity و AsyncTask ارتباطی شکل بگیرد. زمانی که ما back زدیم به درستی SecondActivity بسته شد ولی به علت اینکه AsyncTask هنوز مشغول کار بود و با SecondActivity هم ارتباط داشت در عمل SecondActivity تا زمانی که کار AsyncTask تمام شود در حافظه می ماند.
برای حل این مشکل به سادگی می توان AsyncTask را به شکل کلاس مستقل تعریف کرد. کد را به شکل زیر تغییر می دهیم.
public class SecondActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_second); MyAsyncTask myAsyncTask = new MyAsyncTask(); myAsyncTask.execute(); } }
public class MyAsyncTask extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... voids) { for (int b = 0; b < 100000; b++) { for (int a = 0; a < 100000; a++) { for (int i = 0; i < 100000; i++) { StringBuilder stringBuilder = new StringBuilder(); for (int j = 0; j < 100; j++) { stringBuilder.append(j); } } } } return null; } }
سناریو قبل را مجدد تکرار کرده و Heap را بررسی می کنیم.
تغییرات موفقیت آمیز بود. مشکل Memory Leak حل شد. یک مرحله جلوتر می رویم. یک Interface تعریف می کنیم که بین SecondActivity و AsyncTask ارتباط برقرار کند. کدها به شرح زیر خواهند بود.
public interface MyCallback { void onTaskStarted(); void onTaskFinished(); }
public class SecondActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_second); MyAsyncTask myAsyncTask = new MyAsyncTask(new MyCallback() { @Override public void onTaskStarted() { Toast.makeText(SecondActivity.this, "Start", Toast.LENGTH_SHORT).show(); } @Override public void onTaskFinished() { Toast.makeText(SecondActivity.this, "End", Toast.LENGTH_SHORT).show(); } }); myAsyncTask.execute(); } }
public class MyAsyncTask extends AsyncTask<Void, Void, Void> { private MyCallback myCallback; public MyAsyncTask(MyCallback myCallback) { this.myCallback = myCallback; } @Override protected void onPreExecute() { super.onPreExecute(); myCallback.onTaskStarted(); } @Override protected Void doInBackground(Void... voids) { for (int b = 0; b < 100000; b++) { for (int a = 0; a < 100000; a++) { for (int i = 0; i < 100000; i++) { StringBuilder stringBuilder = new StringBuilder(); for (int j = 0; j < 100; j++) { stringBuilder.append(j); } } } } return null; } @Override protected void onPostExecute(Void unused) { super.onPostExecute(unused); myCallback.onTaskFinished(); } }
سناریو قبل را تکرار می کنیم. آیا Memory Leak رخ می دهد؟ چرا؟
بله مجددا در دام Memory Leak افتادیم. در واقع MyCallback بین SecondActivity و AsyncTask ارتباط بر قرار می کند و مانع پاک شدن آن از Memory می شود.
برای حل این مشکل دو راه عمومی وجود دارد. یکی cancel کردن AsyncTask در متد OnDestroy (که باید در AsyncTask هم چک شود) و دیگری استفاده از WeakReference. مثلا بدین شکل:
private WeakReference<MyCallback> myCallbackWeakReference; public MyAsyncTask(MyCallback myCallback) { myCallbackWeakReference = new WeakReference<>(myCallback); }
اگر به موضوع علاقه داشتید در این ویدیو مثال دیگری از MemoryLeak مطرح شده و برای پیدا کردن آن هم از Memory Profiler استفاده شده است.
Tools not Rules
این جمله ای است که چندین بار در ارائه های مربوط به Performance به گوشم خورد. به نظر من خیلی جمله ی دقیقی است. در عمل قانونِ دقیق و مشخصی برای مباحث Performance وجود ندارد ولی اگر شناخت خوبی از ابزارها داشته باشیم، می توانیم به کمک آن ها مشکلات را پیدا و حل کنیم.
البته ابزارها همه چیز نیستند. هر چه دانش درست تر و عمیق تری نسبت به بخش های مختلف اندروید و زبان داشته باشیم، می توانیم تحلیل درست تری از اطلاعات داشته باشیم. پس علاوه بر یادگیریِ استفاده از ابزارها، باید دانش فنی خود را هم تقویت کنیم.