نشت حافظه در اپلیکیشن های اندروید – از صفر تا صد


ساخت یک اپلیکیشن اندروید کار آسانی است، اما ایجاد یک اپلیکیشن اندروید با کیفیت کاملاً بالا که از نظر حافظه کارآمد باشد چنین نیست. برخی توسعهدهندگان علاقه زیادی به تمرکز و یافتن قابلیتها، کارکردها و اجزای UI جدید در اپلیکیشنهای خود دارند. برخی برنامهنویسان تمایل دارند روی چیزی کار کنند که تأثیر دیداری بیشتری داشته باشد تا این که زمان خود را صرف چیزهایی بکنند که اغلب افراد در نگاه نخست ممکن است حتی متوجه آن نشوند. اما این رویه خوبی نیست، شما باید این عادت اولویتبندی پایینتر موارد مرتبط با بهینهسازی اپلیکیشن را ترک کنید. یکی از مهمترین گامهای بهینهسازی اپلیکیشن نیز اصلاح موارد نشت حافظه است.
این وضعیت به صورت طبیعی منجر به تشکیل یک بدهی فنی میشود که در بلندمدت، شروع به تأثیرگذاری روی کیفیت اپلیکیشنها میکند. شما باید خود را عادت بدهید که به تدریج ذهنیت فوق را که اشاره کردیم کنار بگذارید و بیشتر روی عملکرد اپلیکیشنهای خود متمرکز شوید.
مفهوم نشت حافظه برای بسیاری از توسعهدهندگان آزاردهنده است، چون آن را دشوار، زمانبر، خستهکننده و غیرضروری میپندارند، اما خوشبختانه هیچ کدام از این موارد صحت ندارند. زمانی که شروع به بررسی آن بکنید، متوجه میشوید که موضوع کاملاً زیبا و جذابی است.
در این مقاله تلاش میکنیم این موضوع را تا حد امکان ساده بکنیم، به طوری که حتی توسعهدهندگان تازهکار نیز بتوانند از همان آغاز مسیر حرفهای خود، شروع به ساخت اپلیکیشنهای اندرویدی با کیفیت خوب و عملکرد بالا بکنند.
Garbage Collector دوست شما است؛ اما نه همیشه
جاوا زبان پایداری است. در اندروید کدی به زبانهای C یا ++C نوشته نمیشود (البته به جز برخی موارد نادر). بدین ترتیب مجبور نیستیم که کل فرایند تخصیص حافظه و آزادی سازی آن را شخصاً مدیریت کنیم. خوشبختانه جاوا به خوبی از عهده انجام این امور برمیآید.
پس اینک نخستین سؤالی که به ذهن میآید این است که آیا جاوا یک سیستم مدیریت حافظه اختصاصی درونی دارد که میتواند به صورت خودکار حافظه را در صورت استفاده نشدن پاک کند؟ در این صورت چرا ما به عنوان توسعهدهنده باید در مورد این موضوع دغدغه داشته باشیم؟ آیا Garbage Collector مستعد خطا است؟
پاسخ سؤال فوق منفی است. Garbage Collector دقیقاً همان طور که طراحی شده است کار میکند، اما این اشتباههای برنامهنویسی خود ما است که برخی اوقات Garbage Collector را از گردآوری بخشهای استفاده نشده حافظه بازمیدارد.
بنابراین باید گفت که اساساً این خطای ما است که منجر به بروز مشکل در حافظه میشود. Garbage Collector یکی از برترین دستاوردهای جاوا محسوب میشود و از این رو شایسته احترام است.
توضیحی در خصوص Garbage Collector
پیش از آن که در خصوص موضوع مقاله بیشتر توضیح بدهیم، باید اندکی در مورد طرز کار عملی Garbage Collector بدانیم. تئوری کارکرد Garbage Collector کاملاً ساده است، اما آن چه در پشت صحنه اتفاق میافتد در پارهای موارد کاملاً پیچیده است. در هر حال جای نگرانی نیست، چون ما به طور عمده روی بخشهای ساده آن تمرکز خواهیم داشت.
هر اپلیکیشن اندروید (یا جاوا) باید یک نقطه آغازین داشته باشد که اشیا از آنجا شروع به وهلهسازی میکنند و متدها فراخوانی میشوند. بنابراین میتوانیم این نقطه آغازین را به عنوان ریشه یا root درخت حافظه تصور کنیم. برخی اشیا مستقیماً یک ارجاع به root دارند و برخی دیگر از آنها وهلهسازی میشوند و ارجاع خود را به این اشیا نگه میدارند و همین طور ادامه مییابد.
از این رو یک زنجیره از ارجاعات شکل میگیرد که درخت حافظه را تشکیل میدهند. بنابراین Garbage Collector از ریشههای GC کار خود را آغاز میکند و اشیا را مستقیماً پیمایش میکند یا به صورت غیرمستقیم تا root میپیماید. در انتهای این فرایند، برخی اشیا وجود خواهند داشت که هرگز از سوی GC ملاقات نشدهاند.
این موارد همان Garbage (یا اشیای مرده) هستند و همینها هستند که باید از سوی Garbage Collector دوستداشتنی ما گردآوری شوند. تا به اینجا داستان شبیه به یک افسانه ساده کودکان بوده است، اما در ادامه کمی عمیقتر میشویم تا با جذابیت واقعی کارکرد Garbage Collector آشنا شویم.
نشت حافظه چیست؟
تا به اینجا ایده خلاصهای از ماهیت Garbage Collector و طرز کار عملی مدیریت حافظه در اپلیکیشنهای اندرویدی به دست آوردهایم. اکنون نوبت آن رسیده است که روی موضوع نشت حافظه با جزییات بیشتری تمرکز کنیم.
به بیان ساده نشت حافظه زمانی رخ میدهد که یک شیء را مدتها پس از آن که هدف خود را برآورده ساخت نگهداری کنیم. مفهوم واقعی موضوع به همین سادگی است. هر شیء طول عمر خاص خود را دارد که در زمان پایان یافتن، باید از آن خداحافظی کرد و در نتیجه حافظه را ترک میکند. اما اگر برخی شیء (-های) دیگر به صورت مستقیم یا غیرمستقیم این شیء را نگه دارند، در این صورت Garbage Collector نمیتواند آن را گردآوری کند. این وضعیت همان نشت حافظه است.
اما خبر خوب این است که لازم نیست در مورد وقوع رخداد منفرد نشت حافظه در اپلیکیشن نگران باشید، چون همه نشتهای حافظه باعث از کار افتادن برنامه شما نمیشوند.
برخی نشتها هستند که کاملاً کوچک محسوب میشوند (نشت چند کیلوبایت حافظه)، برخی از نشتها نیز در خود فریمورک اندروید رخ میدهند که شما نمیتوانید و نباید آنها را رفع کنید. این موارد عموماً تأثیر اندکی روی عملکرد اپلیکیشن شما دارند و میتوان آنها را به صورت امنی نادیده گرفت.
اما موارد دیگری هستند که میتوانند اپلیکیشن را از کار بیندازند و آن را به مقدار زیادی کُند سازند به ترتیبی که به زانو درآید. این نشتها آنهایی هستند که باید مراقبشان باشیم.
چرا باید نشتهای حافظه را رفع کرد؟
هیچ کس نمیخواهد از اپلیکیشنی استفاده کند که کُند و دارای وقفه است و پس از چند دقیقه مقدار زیادی از حافظه را مصرف میکنند. این وضعیت میتواند تجربه واقعاً بدی برای کاربر ایجاد کند و اگر برای مدت زیادی طول بکشد، در این صورت احتمال بالایی وجود دارد که آن کاربر را برای همیشه از دست بدهید.
زمانی که کاربر به استفاده از اپلیکیشن شما ادامه میدهد، حافظه heap نیز شروع به افزایش میکند و اگر نشت حافظه در اپلیکیشن رخ بدهد، در این صورت حافظه استفاده نشده در heap نمیتواند از سوی GC رهاسازی شود. بنابراین حافظه heap در اپلیکیشن به صورت مداوم افزایش مییابد تا این که به نقطه مرگ میرسد و حافظه دیگری را نمیتوان به اپلیکیشن تخصیص داد. در این مرحله خطای OutOfMemoryError اعلام شده و در نهایت اپلیکیشن از کار میافتد.
همچنین باید به خاطر داشته باشید که گردآوری garbage یک فرایند سنگین است و از این رو هر چه garbage collector کمتر اجرا شود، برای اپلیکیشن بهتر است.
زمانی که اپلیکیشن مورد استفاده قرار میگیرد و حافظه heap شروع به افزایش میکند، یک GC کوتاه اجرا میشود و شروع به پاکسازی بیدرنگ شیءهای مرده میکند. در این مرحله این GC-ها به صورت همزمان (روی نخ مجزا) اجرا میشوند و اپلیکیشن شما را کُند نمیسازند و در کل یک مکث 2 تا 5 میلیثانیهای خواهد داشت.
اما اگر اپلیکیشن با نشتهای حافظه جدی مواجه باشد که در پشت صحنه پنهان شده باشند، در این صورت GC-های کوتاه قادر به آزادسازی حافظه نخواهند بود و هیپ شروع به افزایش میکند، بدین ترتیب نیاز به یک GC بزرگتر وجود خواهد داشت که عموماً موجب یک مکث از نوع «توقف کامل» (stop-the-world) در نخ اصلی اپلیکیشن میشوند. این مکث حدود 50 تا 100 میلیثانیه زمان طول میکشد و بدین ترتیب باعث میشود که اپلیکیشن دچار وقفه جدی شود و برای مدت زمانی تقریباً غیر قابل استفاده شود.
بدین ترتیب اکنون تأثیر این نشتهای حافظه را که بر روی اپلیکیشن واقع میشوند و همچنین علت این که چرا باید به سرعت آنها را رفع کنیم را میدانیم. بدین ترتیب کاربران بهترین تجربه کاربری را که شایستهاش هستند به دست میآورند.
چگونه نشت حافظه را تشخیص دهیم؟
تا به اینجا احتمالاً به طور کامل قانع شدهاید که باید این نشتهای حافظه را که به صورت پنهان درون اپلیکیشن شما وجود دارند رفع کنید. اما سؤال این است که چگونه میتوان آنها را تشخیص داد؟
خبر خوب این است که اندروید استودیو ابزار بسیار مفید و قدرتمندی به این منظور دارد که Monitors نام دارد. در واقع مانیتورهای منفردی وجود دارند که نه تنها برای نظارت بر مصرف حافظه بلکه برای نظارت بر مصرف CPU و GPU نیز استفاده میشوند.
در زمان استفاده و دیباگ کردن اپلیکیشن میتوانید نگاهی به این مانیتور حافظه داشته باشید. نخستین نشانههای نشت حافظه هنگامی رخ میدهند که وقتی به استفاده از اپلیکیشن ادامه میدهید، گراف مصرف حافظه به سرعت رو به فزونی میگذارد و هرگز حتی هنگامی که اپلیکیشن در پس زمینه اجرا میشود، پایین نمیآید.
Allocation Tracker در مواردی که بتوانید از آن برای بررسی درصد حافظه تخصیصیافته به انواع شیءها در اپلیکیشن استفاده کنید بسیار کارآمد خواهد بود. بدین ترتیب میتوان ایده روشنی از این که کدام اشیا بخش زیادی از حافظه را اشغال کردهاند و باید به طور خاص مورد رسیدگی قرار گیرند، به دست آورد.
اما این کار به تنهایی کافی نیست، چون اکنون باید از گزینه Dump Java Heap برای ایجاد یک heap dump استفاده کنید که در عمل نماینده یک اسنپشات از حافظه در نقطه خاصی از زمان است. چنان که میبینید این وضعیت نیازمند مقدار زیادی کار خستهکننده و تکراری است.
ما مهندسان غالباً از کارهای دشوار اجتناب میکنیم و این دقیقاً همان موقعیتی است که LeakCanary (+) به کمک ما میآید. این کتابخانه همراه با اپلیکیشن اجرا میشود و حافظه را در زمانهای مورد نیاز dump میکند و به دنبال موارد احتمالی نشت حافظه میگردد تا به همراه یک رد پشته تمیز و مفید به شما اعلان کند و بدین ترتیب میتوانید ریشه نشت حافظه را پیدا کنید. LeakCanary امکان تشخیص نشت حافظه را برای همه افراد به طور آسانی فراهم ساخته است.
برخی سناریوهای رایج نشت حافظه و روش اصلاح آنها
برخی سناریوهای بسیار رایج نشت حافظه وجود دارند که بروز آنها بسیار محتمل است و اغلب در تجربه توسعه روزمره اندروید با این سناریوها مواجه میشویم. زمانی که بدانید چه هنگام، کجا و چگونه نشتهای حافظه رخ میدهند، میتوانید بدون دشواری اقدام به اصلاح آنها بکنید.
شنوندههای ثبت نشده
موقعیتهای زیادی وجود دارند که شما یک «شنونده» (Listener) را در اکتیویتی (یا فرگمان) خود ثبت میکنید، اما فراموش میکنید آن را از فهرست ثبت نام خارج کنید. در صورتی که خوششانس نباشید، این وضعیت به سادگی منجر به نشت حافظه زیادی میشود. به طور کلی این شنوندهها همدیگر را متعادل میسازند و از این رو اگر جایی در اپلیکیشن ثبت شوند باید، آنها را دقیقاً همان جا از فهرست ثبت خارج کنید.
اینک نوبت آن رسیده است که یک مثال ساده را بررسی کنیم. فرض کنید میخواهید بهروزرسانیهای مکان را در اپلیکیشن خود به دست آورید و از این رو باید سرویس سیستم LocationManager را استفاده کرده و یک شنونده برای بهروزرسانی موقعیت ثبت کنید.
1private void registerLocationUpdates(){
2mManager = (LocationManager) getSystemService(LOCATION_SERVICE);
3mManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,
4 TimeUnit.MINUTES.toMillis(1),
5 100,
6 this);
7}
شما این اینترفیس شنونده را در اکتیویتی خود پیادهسازی میکنید و از این رو LocationManager یک ارجاع به آن نگهداری میکند. اکنون اگر زمان آن رسیده باشد که اکتیویتی متوقف شده و فریمورک اندروید متد ()onDestroy را روی آن فراخوانی کند، اما garbage collector نخواهد توانست وهلهای از آن را از حافظه پاک کند، زیرا LocationManager همچنان یک ارجاع قوی برای آن نگهداری میکند.
راهحل بسیار ساده است. کافی است شنونده را در متد ()onDestroy ثبت کنید و بدین ترتیب مشکل حل میشود. این جزییاتی است که اغلب ما فراموش میکنیم و یا شاید حتی نمیدانیم.
1@Override
2public void onDestroy() {
3 super.onDestroy();
4 if (mManager != null) {
5 mManager.removeUpdates(this);
6 }
7}
کلاسهای داخلی
کلاسهای داخلی در جاوا بسیار رایج هستند و به دلیل سادگیشان از سوی بسیاری از توسعهدهندگان اندروید برای وظایف مختلفی استفاده میشوند. اما اگر به صورت درستی از آنها استفاده نمیکنید، همین کلاسهای داخلی میتوانند منجر به نشتهای حافظه بالقوهای شوند.
در ادامه به کمک یک مثال ساده به بررسی این وضعیت میپردازیم.
1public class BadActivity extends Activity {
2 private TextView mMessageView;
3 @Override
4 protected void onCreate(Bundle savedInstanceState) {
5 super.onCreate(savedInstanceState);
6 setContentView(R.layout.layout_bad_activity);
7 mMessageView = (TextView) findViewById(R.id.messageView);
8 new LongRunningTask().execute();
9 }
10 private class LongRunningTask extends AsyncTask<Void, Void, String> {
11 @Override
12 protected String doInBackground(Void... params) {
13 return "Am finally done!";
14 }
15 @Override
16 protected void onPostExecute(String result) {
17 mMessageView.setText(result);
18 }
19 }
20}
این یک اکتیویتی کاملاً ساده است که یک وظیفه با اجرای بلندمدت را در نخ پس زمینه آغاز میکند. این وظیفه میتواند یک کوئری پایگاه داده یا یک فراخوانی کُند شبکه باشد. پس از این که وظیفه پایان یابد، نتیجه در یک TextView نمایش مییابد. ظاهر همه چیز خوب است.
البته مشکلی وجود دارد. مشکل این است که در این کد کلاس داخلی غیر استاتیک یک ارجاع صریح به کلاس احاطه کننده بیرونی خود (که همان اکتیوتی است) نگهداری میکند. اکنون اگر صفحه را بچرخانید یا اگر وظیفه بلندمدت، بیشتر از عمر اکتیویتی طول بکشد، garbage collector نخواهد توانست وهلهای از یک اکتیوتی کامل را از حافظه پاک کند. بدین ترتیب یک خطای ساده منجر به نشت حافظه بزرگی میشود.
اما در این مورد نیز راهحل کار آسان است و کافی است نگاهی به کد زیر بیندازید تا آن را تشخیص دهید:
1public class GoodActivity extends Activity {
2 private AsyncTask mLongRunningTask;
3 private TextView mMessageView;
4 @Override
5 protected void onCreate(Bundle savedInstanceState) {
6 super.onCreate(savedInstanceState);
7 setContentView(R.layout.layout_good_activity);
8 mMessageView = (TextView) findViewById(R.id.messageView);
9 mLongRunningTask = new LongRunningTask(mMessageView).execute();
10 }
11 @Override
12 protected void onDestroy() {
13 super.onDestroy();
14 mLongRunningTask.cancel(true);
15 }
16 private static class LongRunningTask extends AsyncTask<Void, Void, String> {
17 private final WeakReference<TextView> messageViewReference;
18 public LongRunningTask(TextView messageView) {
19 this.messageViewReference = new WeakReference<>(messageView);
20 }
21 @Override
22 protected String doInBackground(Void... params) {
23 String message = null;
24 if (!isCancelled()) {
25 message = "I am finally done!";
26 }
27 return message;
28 }
29 @Override
30 protected void onPostExecute(String result) {
31 TextView view = messageViewReference.get();
32 if (view != null) {
33 view.setText(result);
34 }
35 }
36 }
37}
آن چنان که میبینید، ما کلاس داخلی غیر استاتیک را به یک کلاس داخلی استاتیک تبدیل کردهایم، چون کلاس داخلی استاتیک هیچ ارجاع صریحی به کلاس محاط بیرونی خود نگه نمیدارد. اما اینک از یک کانتسکت استاتیک دیگر به متغیرهای غیر استاتیک (مانند TextView) در کلاس بیرونی دسترسی نداریم و از این رو مجبور هستیم ارجاع شیءهای مورد نیاز را از طریق سازنده به کلاس درونی ارسال کنیم.
قویاً توصیه میشود که این ارجاعهای شیء را در یک WeakReference قرار دهیم تا از بروز نشتهای حافظه بیشتر جلوگیری کنیم. بدین ترتیب شما باید با انواع مختلف ارجاعهای موجود در جاوا آشنا باشید و بدانید که چگونه میتوانید بهترین استفاده از آنها را برای جلوگیری از بروز نشت حافظه داشته باشید.
کلاسهای بی نام
کلاسهای بی نام در میان اغلب توسعهدهندگان محبوب هستند، زیرا روشی که برای تعریف آنها استفاده میشود موجب میشود که نوشتن کد با استفاده از آنها بسیار آسان و فشرده باشد. اما برخی از توسعه دهنگان نیز بر این باور هستند که این کلاسهای بی نام مهمترین دلیل بروز نشتهای حافظه هستند.
کلاسهای بی نام چیزی به جز کلاسهای داخلی غیر استاتیک نیستد که موجب بروز نشتهای حافظه بالقوه میشوند و در مورد دلیل این مسئله در بخش قبلی توضیح دادیم. شما ممکن است در چند جای مختلف اپلیکیشن از آنها استفاده کنید اما شاید این موضوع را ندانید که اگر روش استفاده از آنها نادرست باشد میتواند تأثیر شدیدی بر روی عملکرد اپلیکیشن داشته باشد.
1public class MoviesActivity extends Activity {
2 private TextView mNoOfMoviesThisWeek;
3 @Override
4 protected void onCreate(Bundle savedInstanceState) {
5 super.onCreate(savedInstanceState);
6 setContentView(R.layout.layout_movies_activity);
7 mNoOfMoviesThisWeek = (TextView) findViewById(R.id.no_of_movies_text_view);
8 MoviesRepository repository = ((MoviesApp) getApplication()).getRepository();
9 repository.getMoviesThisWeek()
10 .enqueue(new Callback<List<Movie>>() {
11
12 @Override
13 public void onResponse(Call<List<Movie>> call,
14 Response<List<Movie>> response) {
15 int numberOfMovies = response.body().size();
16 mNoOfMoviesThisWeek.setText("No of movies this week: " + String.valueOf(numberOfMovies));
17 }
18 @Override
19 public void onFailure(Call<List<Movie>> call, Throwable t) {
20 // Oops.
21 }
22 });
23 }
24}
در کد فوق ما از یک کتابخانه بسیار محبوب به نام Retrofit (+) برای ایجاد یک فراخوانی شبکه و نمایش نتیجه در یک TextView استفاده میکنیم. کاملاً روشن است که این شیء قابل فراخوانی نیز یک ارجاع به کلاس اکتیویتی محاط خود نگه میدارد.
اکنون اگر فراخوانی شبکه روی یک اتصال بسیار کُند اجرا شود و پیش از فراخوانی پایان گیرد، اگر اکتیویتی به نوعی دچار چرخش یا تخریب شود، در این صورت کل وهله اکتیویتی نشت خواهد یافت.
استفاده از کلاسهای داخلی استاتیک به جای کلاسهای بی نام در همه مواردی که ممکن یا ضروری باشد، یک توصیه کاملاً مفید محسوب میشود. البته به این آن معنی نیست که توصیه کنیم استفاده از کلاسهای بی نام را بیدرنگ و به طور کامل متوقف کنید، بلکه باید در مورد این که چه زمان استفاده از آنها امن است و چه زمان چنین نیست، درک مناسبی داشته و در مورد آن قضاوت کنید.
Bitmap
هر تصویری که در اپلیکیشن میبینید چیزی به جز اشیای Bitmap نیست که شامل دادههای پیکسلی یک تصویر است. این اشیای بیتمپ عموماً کاملاً سنگین هستند و اگر به درستی با آنها برخورد نشود، منجر به نشت قابل توجهی در حافظه میشوند و میتوانند در نهایت باعث از کار افتادن اپلیکیشن به دلیل خطای OutOfMemoryError شوند. حافظه بیتمپ مرتبط با منابع تصویر که در اپلیکیشن استفاده میکنید همیشه به صورت خودکار از سوی فریمورک مدیریت میشوند، اما اگر بیتمپها را به صورت دستی مدیریت کنید، باید مطمئن شوید که آنها را پس از استفاده ()recycle میکنید.
همچنین باید بدانید که چگونه میتوان بیتمپها را به درستی مدیریت کرد. بارگذاری بیتمپهای بزرگ به وسیله مقیاسبندی کردن آنها و استفاده از کش کردن بیتمپ و pool کردن در موارد مقتضی موجب کاهش مصرف حافظه میشود.
Context
دلیل مهم دیگر بروز نشت حافظه سوءاستفاده از وهلههای Context است. Context صرفاً یک کلاس مجرد است و کلاسهای زیادی (مانند Activity, Application, Service و غیره) وجود دارند که آن را بسط میدهند تا کارکردهای خاص خود را ارائه دهند.
اگر میخواهید کارها را در اندروید انجام دهید، شیء Context بهترین همراه شما است. اما بین این Context-ها تفاوت وجود دارد. درک تفاوت بین Context سطح اکتیویتی و Context سطح اپلیکیشن و این که کدام یک در کدام شرایط استفاده میشود، بسیار حائز اهمیت است.
استفاده از Context اکتیویتی در مکان نادرست باعث میشود که ارجاعی به کل اکتیویتی نگهداری شود و موجب بروز نشتهای حافظه بالقوه میشود.
سخن پایانی
اینک باید متوجه شده باشید که Garbage Collector چگونه کار میکند، نشت حافظه چیست و چگونه میتواند تأثیر عمدهای روی عملکرد اپلیکیشن داشته باشد. همچنین با روش تشخیص و اصلاح این نشتهای حافظه آشنا شدهاید. بنابراین دیگر عذر موجهی ندارید و از این پس باید شروع به ساخت اپلیکیشنهای اندرویدی با کیفیت خوب و عملکرد بالا بکنید. تشخیص و اصلاح کردن نشتهای حافظه نه تنها موجب میشود که تجربه کاربری بهتری پدید آید، بلکه شما را نیز به توسعهدهنده بهتری تبدیل میکند.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای پروژهمحور برنامهنویسی اندروید
- گنجینه برنامه نویسی اندروید (Android)
- مجموعه آموزشهای برنامهنویسی اندروید
- برنامهنویسی موبایل با اندروید استودیو
- ۵ گام ضروری برای یادگیری برنامهنویسی اندروید — راهنمای جامع
==