ویرگول
ورودثبت نام
مهدی ایران نژاد
مهدی ایران نژادبرنامه نویس و توسعه دهنده بازی های اندرویدی در Unity، طراح UI
مهدی ایران نژاد
مهدی ایران نژاد
خواندن ۱۲ دقیقه·۴ ماه پیش

جنگ من و Unity با اندروید بر سر یک دکمه کوچک!

مقدمه شروع:

توی این مقاله قراره با زبان طنز-فنی، یه سفر کوتاه اما پر از پیچ و خم رو با هم طی کنیم؛ سفری به دل ماجرایی عجیب غریب که سر راه توسعه بازیمون توی یونیتی سبز شد و کلی اعصاب خوردی، آزمون و خطا و البته تجربه با خودش آورد.
راه حل نهایی من همون جاییه که با «قهرمان پوشالی‌مون» یعنی Custom Activity خداحافظی کردم، ولی اگه دلتون میخواد بدونید چی شد که کار به اینجا رسید، پیشنهاد میکنم مقاله رو از اول بخونید.
قول میدم یه لبخند کوچولو هم شده گوشه لبتون سبز بشه ;-)

آپدیتی که همه چیز داشت، جز دکمه Back سالم

همه چیز آماده بود. یه بروزرسانی اساسی برای بازی داشتیم، حدود 2 ماه و نیم داشتیم روی پروژه کار میکردیم، همه چیز تست شده، همه صحنه ها سالم، حتی QA هم چراغ سبز داده بود؛ فقط مونده بود دکمه معروف "Release" رو بزنیم و با خیال راحت آپدیت جدید رو بفرستیم روی گوگل پلی. اما... همیشه اون لحظه آخر یه چیزی میاد میزنه برجکمون رو خراب میکنه، چرا؟ چون دنیا که رو یه پاش نمیچرخه...

گوگل پلی اومد گفت: "اگه میخوای آپدیت جدید بدی، باید Target SDK رو حداقل بذاری روی 35." ما هم گفتیم باشه ولی با یه ذره شیطنت! گذاشتیمش روی 36 که بگیم آره ما خیلی آپدیتیم داداش😎

اینجا بود که کم کم آسمون برامون ابری شد. چون با بالا رفتن Target SDK به 36، دیگه Min SDK 22 جواب نمیداد و Gradle همون اول بیلد میگفت: "من با 22 نمیسازم!" پس مجبور شدیم Min SDK رو هم ببریم بالا، شد 23.

تا اینجای کار گفتیم خب، یه مقدار هزینه برای آپدیت میدیم، ولی حداقل خیالمون راحته. اما نه... سه هفته بعد، یه سری گزارش عجیب رسید از سمت کاربران اندروید ۱۶. میگفتن وقتی دکمه Back رو که میزنن، بازی نه Pause میشه، نه به صفحه قبل برمیگرده، بلکه یهو بسته میشه یا میره توی Background!
انگار که دکمه Back شده بود دکمه "برو بیرون و پشت سرتو هم نگاه نکن!" 😐

با اولین تست فنی روی Android 16، دکمه Back زده بود زیر همچی. به جای اینکه بازی برگرده به منو یا صفحه قبل، کل اپ میپرید بیرون یا Minimize میشد. حتی Unity هم انگار اصلاً خبر نداشت که دکمه Back فشار داده شده!

جالبتر اینکه این مشکل فقط روی اندروید ۱۶ اتفاق می افتاد و بازی روی بقیه اندروید های قدیمی، کاملاً درست کار میکرد. انگاری که Android 16 مثل بچه وسط خانواده قهر کرده بود. 😒

Unity روی اندروید 16 اصلا Input.GetKeyDown(KeyCode.Escape) رو تشخیص نمیداد و هیچ واکنشی براش نداشت.

اما ته ماجرا چی بود؟ گوگل از Android 13 به بعد اومده یه قابلیتی اضافه کرده به اسم Predictive Back Gesture، قابلیتی که توی Android 16 شدیدتر اعمال میشه؛ این یعنی وقتی کاربر دکمه Back رو میزنه، سیستم قبل از انجام هر کاری یه بررسی میکنه ببینه دقیقاً باید چه رفتاری نشون بده: عقب بره؟ جلو بره؟ App رو ببنده؟ هیچی نگه؟ ولی خب اینجا Unity مثل همیشه سرش تو لاک خودش بود، اصلاً حواسش نبود این دکمه الان شده دکمه همه فن حریف! نتیجه؟ کاربر دکمه رو میزنه، سیستم میگه "خب این برنامه هیچی نگفت، پس من خودم تصمیم میگیرم"، و در یک عملیات انتحاری میزنه بازی رو میبنده بدون اینکه Unity فرصت داشته باشه یه خداحافظی خشک و خالی کنه.

مسیر پرماجرای نجات دکمه Back

اولین واکنش ما بعد از کشف مشکل دکمه Back این بود که بگیم:
«خب نکنه Unity خودش یه تنظیماتی داره برای اینجور مواقع؟»

با یک سرچ ساده رفتیم سراغ آشناترین و در دسترس ترین گزینه:

Input.backButtonLeavesApp = false;

این خط همیشه یه حس اطمینان خاصی میداد. معنیش هم ساده بود:
«یونیتی جون، جان جدت لطفاً دکمه Back رو دست خودت نگه دار، نذار سیستم بزنه بازی رو minimize کنه یا ببنده.»

ولی ظاهراً این خط کد توی اندروید 16 دیگه اون اعتباری که قبلاً داشت رو از دست داده بود.
انگار اندروید جدید رسماً اعلام کرده بود که:
«من دیگه به Unity گوش نمیدم. تو دکمه بک رو بزنی هم من بازی رو میفرستم پشت صحنه، حالا هرچی میخوای بگو.»

نتیجه؟ هیچگونه تغییر خاصی در رفتار Back مشاهده نشد. حتی در لاگ ها هم اثری از پردازش توسط یونیتی نبود.
در واقع، سیستم عامل مستقیماً کنترل رو در دست گرفته بود و Unity عملاً کنار گذاشته شده بود.

در نهایت، این روش رو گذاشتیم کنار. چون هر چقدر هم که nostalgically دلبسته اش بودیم، توی اندروید 16 دیگه هیچ کاری برامون نمیکرد.

Back در حال فرار 😂
Back در حال فرار 😂

وقتی گفتیم «خودمون Back رو هندل کنیم!»

بعد از اینکه فهمیدیم یونیتی با Input.backButtonLeavesApp = false هیچ واکنشی به اندروید 16 نشون نمیده، رفتیم سراغ راه های عمیق تر؛ اینجا بود که تصمیم گرفتیم خودمون دست به کار بشیم و یک Activity سفارشی بسازیم تا دقیقاً همونطور که ما میخوایم، با دکمه Back رفتار کنه.

در اصل، یونیتی یه Activity اصلی به داره به اسم UnityPlayerActivity (و اگه فایربیس استفاده کنی، MessagingUnityPlayerActivity رو استفاده میکنه که از همون ارث بری شده).

داخل یونیتی های ورژن 2023.1 و جدیدتر، اکتیویتی اصلیUnityPlayerGameActivityاست.


ایده ما این بود که بیایم یه اکتیویتی کلاس جدید درست کنیم که ازMessagingUnityPlayerActivity ارث بری کنه (چون ما داخل پروژه فایربیس رو داشتیم) و توی اون، متدهای مربوط به ایونت Back مثل onBackPressedوOnBackInvokedCallbackرو خودمون مدیریت کنیم.

به زبان ساده تر، گفتیم:
«به جای اینکه به یونیتی یا اندروید بگیم Back رو مدیریت کن، بیایم خودمون کار رو دست بگیریم.»

کد نمونه اولیه چیزی شبیه این بود:

Assets/Plugins/Android/CustomUnityPlayerActivity.java:

/// Java class is generated and saved in the `Assets/Plugins/Android/CustomUnityPlayerActivity.java` /// Replace `[dot]` with `.` package com[dot]yourcompany[dot]yourgame; import android[dot]os[dot]Build; import android[dot]os[dot]Bundle; import android[dot]view[dot]KeyEvent; import com[dot]google[dot]firebase[dot]MessagingUnityPlayerActivity; import com[dot]unity3d[dot]player[dot]UnityPlayer; import android[dot]window[dot]OnBackInvokedCallback; import android[dot]window[dot]OnBackInvokedDispatcher; public class CustomUnityPlayerActivity extends MessagingUnityPlayerActivity { private OnBackInvokedCallback predictiveBackCallback; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { predictiveBackCallback = this::onBackPressed; getOnBackInvokedDispatcher().registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, predictiveBackCallback); } } @Override protected void onDestroy() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && predictiveBackCallback != null) { getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(predictiveBackCallback); } super.onDestroy(); } @Override public void onBackPressed() { UnityPlayer.currentActivity.runOnUiThread(() -> { UnityPlayer.currentActivity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK)); UnityPlayer.currentActivity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK)); }); } }

Assets/Plugins/Android/AndroidManifiest.java:

/* Replace `[dot]` with `.` */ <application ... other tags ...> <activity android:name="com[dot]yourcompany[dot]yourgame[dot]CustomUnityPlayerActivity" /* Changed this line to use CustomUnityPlayerActivity */ android:enableOnBackInvokedCallback="true" /* Added this line */ ... other tags ... > .... .... .... </activity> .... .... <meta-data android:name="unityplayer.ForwardNativeEventsToDalvik" android:value="true" /> /* Added this line */ </application>

این تغییرات اومده یه جورایی با سیستم عامل وارد مذاکره میشه! اونجوری که توی متد onCreate با یه OnBackInvokedCallback قرارداد میبنده و به اندروید میگه: "داداش، اگه کاربری خواست BACK بزنه چه از طریق اون Predictive Back، چه از طریق سیستم های قدیمی در اندروید های قبل از 13، اول منو صدا بزن، خودم میدونم باید چیکارش کنم."
و ما هم وقتی کلاسمون صدا زده میشه، خیلی شیک و مجلسی، یک رویداد BACK تقلبی و دستی برای Unity میفرستیم تا بازی طبق منطق خودش رفتار کنه، نه اینکه یهو minimize بشه یا ببنده بره قاقا قیلی. 😂

حالا متد onBackPressed هم که از قدیم اونجا بود، برای نسخه های پایین تر از 13 همچنان باید آستین بالا بزنه. چون ما عملاً کنترل Back رو از چنگ سیستم عامل کشیدیم بیرون، خودمون باید با دوتا dispatchKeyEvent (یکی DOWN و یکی UP) وانمود کنیم که کاربر دکمه BACK رو فشار داده، تا Unity بتونه طبق روال طبیعی خودش باهاش برخورد کنه.

در onDestroy هم اگه CallBack قبلاً ثبت شده باشه، خیلی مؤدبانه unregisterش میکنیم که بعداً توی حافظه ول چرخی نکنه یا موقع خروج به Dispatcher گیر بده. (به قول معروف، خانه تکانی قبل از رفتن.)

خلاصه اینکه CustomUnityPlayerActivity دقیقاً یه جور واسطه گر حرفه ایه: نه میذاره اندروید یه تنه تصمیم بگیره، نه اجازه میده Unity بی خبر بمونه. همه چیز رو با دیپلماسی دقیق بین این دو تا غول بی سر و ته مدیریت میکنه، طوری که هم سیستم راضی باشه، هم بازی، گور پدر ناراضی.

همه چیز خوب بود تا اینکه اندروید 10 رسید...

بالاخره بعد از کلی تست و ور رفتن با اکتیویتی سفارشی، دکمه Back مثل ساعت داشت کار میکرد نه تنها روی اندروید 16 حتی قبلی ها هم دیگه مثل بچه آدم حرف گوش میکردن. بازی خوشگل اجرا میشد، Back هم تابع قوانین Unity شده بود و دیگه هم کسی بیرون نمی پرید!

تا اینکه... اندروید 10 (همون API 29) و نسخه های قدیمی ترش یهویی اومدن وسط و گفتن:

"آقا ببخشید، mUnityPlayer کیه دقیقاً؟ ما همچین چیزی نمیشناسیم! مامااااان...."

AndroidJavaException: java.lang.NoSuchFieldError: mUnityPlayer

و بوووووم! سیستم به کل قهر کرد و بازی روی اندروید 10 فقط با نگاه به اسم mUnityPlayer سکته میکرد.

واکنش من این لحظه: «نه، نه، این حقیقت ندارد، این حقیقت ندااارررد...😭😭»

پس از کلی بررسی و کاوش متوجه شدم که این خطا فقط روی اندروید 10 و پایین تر رخ میده و باعث میشه کلاس های مربوط به Java Native Interface در Unity از جملهAndroidJavaObject,AndroidJavaClass و AndroidJNI عملاً از کار بیفتن و تمامی پلاگین هایی که داخل یونیتی قصد کارکردن با سیستم عامل و Java رو داشتن مثل Notification, Firebase و موارد مشابه با اختلال جدی مواجه بشن.

نکته مرموز اینجا بود که متغیر mUnityPlayer در اصل از نوع protected بود و تو نسخه های بالاتر براحتی از طریق reflection قابل دستیابی بود. اما در اندروید 10، ساختار reflection کمی سنتی تر و سختگیرتر بود. در واقع، از اندروید 11 به بعد reflection یه سری قابلیت ها و تغییرات جدید دریافت کرده بود که باعث میشد دسترسی به فیلدهای protected راحت تر باشه، ولی اندروید 10 هنوز اون آپدیتها رو نداشت.

نتیجه چی بود؟ ارور NoSuchFieldError بی مقدمه وسط اجرا می پرید و کل ارتباطات جاوا-یونیتی رو منفجر میکرد.

ماجراجویی هایم برای نجات از خطای mUnityPlayer

برای نجات از این وضعیت، دست به یه سری عملیات زدم:

پروگارد را التماس کردم:
یه عالمه keep- نوشتم بلکه جلوی حذف چیزایی که حتی نمیدونم اصلاً وجود دارن یا نه رو بگیرم.

Reflection درمانی:
نشستم با Java Reflection بازی کردم که شاید بتونم با getDeclaredField("mUnityPlayer") نجاتش بدم. نتیجه چی بود؟ یه ارور محکم تو صورتم! از اوناش که اگه مزاحم یه دختر بشی سیلی اش رو میخوری! 😂

Copy/Paste درمانی:
گفتم بیام به جای ارث بری از MessagingUnityPlayerActivity، کل کدهاش رو خودم مستقیم توی کلاس CustomUnityPlayerActivity پیاده کنم و از UnityPlayerActivity ارث بری کنم. فکر میکردم با این حرکت، کنترل کامل دست خودمه. بازم نتیجه چی بود؟ خب میدونی، از بیرون قشنگ بود، ولی همچنان اندروید 10 باهام لج بود.

مهندسی معکوس برای متغیر mUnityPlayer:
تلاش کردم خودم یه متغیر public به اسمmUnityPlayerتعریف کنم و به جای اصلیه قالب کنم ولی این بار باز هم سیستم عامل با چشم غره جوابم رو داد.

سکوت و نادیده گرفتن:
یه مرحله ای فقط نگاهش میکردم و وانمود میکردم ارور نیست. خب اینم جواب نداد ولی روحیه امو حفظ کرد.

نتیجه ماجراجویی

نتیجه تمام این تلاش ها چی بود؟ فهمیدم ور رفتن با هر چیزی باعث میشه باهات ور رفته بشه و دسترسی به اعضای non-public روی اندرویدهای قدیمی به وسیله reflection مثل تلاش برای هک کردن یخچال با برنامه هواشناسیه.
هر چقدر هم کد درست باشه، وقتی دسترسی مورد نظرت توی سیستم عامل اصلاً وجود نداره، فقط خودتو سرویس کردی.

خداحافظ با قهرمان پوشالی، Custom Activity

بعد از ساعت ها جنگ بی امان با انواع ارث بری، کالبک، پراکسی و reflection، بالاخره رسیدم به یه واقعیت تلخ ولی نجات بخش: گاهی بهترین کار، نکردنه. بالاخره پذیرفتم که شاید اصلاً نیازی به دست کاری Activity اصلی نیست!
نه به خاطر کم آوردن زیر فشار خطاها، بلکه چون عملاً هر بار که سعی میکردم مسیرش رو ادامه بدم، یه جای کار میلنگید؛ یا یه باگ جدید ظاهر میشد، یا یه نسخه از اندروید ناراحت میشد، یا خود یونیتی قهر میکرد، یا سیستم عامل درخواست طلاق میداد؛ خلاصه، تصمیم گرفتم یه قدم عقب تر بردارم و محترمانه، پرچم سفید رو بالا ببرم و بگم:

"اکتیویتی سفارشی عزیز، ازت ممنونم، ولی دیگه ادامه این راه با من نیست..."

و در نهایت به برگشتم عقب و به چیزی فکر کردم که از اول باید میکردم:
“چطور میتونم فقط و فقط دکمه BACK رو هندل کنم، بدون اینکه به اکتیویتی دست بزنم؟”

و جواب خیلی ساده تر از اون چیزی بود که فکرشو میکردم:
یه پلاگین جاوا با یه کلاس معمولی، بدون اکتیویتی، بدون ارث بری، بدون هیچ چیزی، فقط یک واسطه بین Android و Unity. یک راهکار راحت، مینیمال، و بدون دردسرهای reflection و پروگارد، سازگار با گوشیهای نوستالژیک حتی اندروید 6 و حتی بدون جنگ اعصاب!

بازی در این مرحله تو همه نسخه ها از اندروید 6 گرفته تا 16 مثل ساعت کار کرد. نه Back عجله ای داشت، نه Unity قهر میکرد. همه چیز مثل یه گربه اهلی شده، سر جاش نشست و دقیقاً همون رفتاری رو کرد که انتظار داشتم.

پس گاهی وقتا راه حل درست، همون ساده ترینشه... فقط باید از پیچیده بودن دست برداریم!

پیاده سازی Back Handler بدون تغییر در اکتیویتی

خب، ما تو این روش اصلاً دست به اکتیویتی نمیزنیم. به جاش یه کلاس Java میسازیم با اسم UnityBackHandlerکه به عنوان یه "شنونده محترم برای دکمه Back" استخدام شده.

البته ناگفته نماند، کلاس CustomUnityPlayerActivity رو از پروژه حذف کردم و از همان activity پیش فرض و قبلی پروژه داخل AndroidManifiest استفاده کردم.

Assets/Plugins/Android/UnityBackHandler.java:

/// Java class is generated and saved in the `Assets/Plugins/Android/UnityBackHandler.java` /// Replace `[dot]` with `.` package com[dot]yourcompany[dot]yourgame; import android[dot]app[dot]Activity; import android[dot]os[dot]Build; import android[dot]view[dot]KeyEvent; import android[dot]view[dot]View; import android[dot]window[dot]OnBackInvokedCallback; import android[dot]window[dot]OnBackInvokedDispatcher; import com[dot]unity3d[dot]player[dot]UnityPlayer; public class UnityBackHandler { private static OnBackInvokedCallback predictiveBackCallback; private static boolean isInitialize = false; public static void Setup() { if (isInitialize) return; final Activity activity = UnityPlayer.currentActivity; activity.runOnUiThread(() -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { predictiveBackCallback = BackHandler::dispatchUnityBack; activity.getOnBackInvokedDispatcher().registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, predictiveBackCallback); } View decorView = activity.getWindow().getDecorView(); decorView.setFocusableInTouchMode(true); decorView.requestFocus(); decorView.setOnKeyListener((v, keyCode, event) -> { if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) { dispatchUnityBack(); return true; } return false; }); }); isInitialize = true; } private static void dispatchUnityBack() { Activity activity = UnityPlayer.currentActivity; activity.runOnUiThread(() -> { activity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK)); activity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK)); }); } public static void Destroy() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && predictiveBackCallback != null) { UnityPlayer.currentActivity.getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(predictiveBackCallback); } } }

Assets/Plugins/Android/AndroidManifiest.java:

<application ... other tags ...> <activity android:name="XXXXXXXXX" /* Use old activity like `UnityPlayerActivity` or `MessagingUnityPlayerActivity` depends on your project */ android:enableOnBackInvokedCallback="true" /* Added this line */ ... other tags ... > .... .... .... </activity> .... .... <meta-data android:name="unityplayer.ForwardNativeEventsToDalvik" android:value="true" /> /* Added this line */ </application>

تا اینجای کار تغییرات مورد نیاز در سطح Java روی پروژه اعمال شده، الان به یک فعال ساز نیاز داریم که کد کلاس UnityBackHandler رو در سطح Unity برای ما فعال کنه، پس یک کلاس یونیتی به اسم AndroidBackDispatcher داخل یونیتی میسازیم (با ارث بری از MonoBehaviour) و دستورات زیر را داخل آن قرار میدهم:

using UnityEngine; public class AndroidBackDispatcher : MonoBehaviour { private void Awake() { if (Application.platform != RuntimePlatform.Android) Destroy(gameObject); #if UNITY_ANDROID && !UNITY_EDITOR using (var javaClass = new AndroidJavaClass("com[dot]yourcompany[dot]yourgame[dot]UnityBackHandler")) /// Replace `[dot]` with `.` { javaClass.CallStatic("Setup"); } #endif } private void OnApplicationQuit() { #if UNITY_ANDROID && !UNITY_EDITOR using (var javaClass = new AndroidJavaClass("com[dot]yourcompany[dot]yourgame[dot]UnityBackHandler")) /// Replace `[dot]` with `.` { javaClass.CallStatic("Destroy"); } #endif } }

فراموش نکنیم که این کلاس باید به یک آبجکت در اولین مرحله از اجرای بازی متصل شود تا عملیات به درستی اجرا شود. ضمنا برای پیشگیری از هرگونه مشکلات احتمالی خطوط زیر رو به انتهای فایل پروگارد پروژه نیز اضافه میکنیم:

/// Replace `[dot]` with `.` -keep class com[dot]yourcompany[dot]yourgame[dot]UnityBackHandler { public static void Setup(); public static void Destroy(); }

خب کار تمام شد، لعنت‌ گویان به گوگل، بریم سراغ ماجراجویی بعدیمون….


منتظر نظرات و پیشنهادات شما هستم🌹🌹

اندرویدunityبازی سازییونیتی
۰
۰
مهدی ایران نژاد
مهدی ایران نژاد
برنامه نویس و توسعه دهنده بازی های اندرویدی در Unity، طراح UI
شاید از این پست‌ها خوشتان بیاید