(קוד) אחד בשביל כולם!


פורסם ב 28/11/2013 ע"י Royi Benyossef

פרולוג התנצלות

שלום לכם קהל יקר,

לקח יותר מידי זמן לכתוב הפוסט הזה (חודשיים) אבל אני מקווה שתסלחו לי ברגע שתראו מה הבאתי בפניכם פה.

למי ששכח, הוקרה קצרה; הבלוג הזה מתבסס לחלוטין על עבודתי המשותפת עם +Ran Nachmany ו- +Amir Lazarovich על Developer lab בשם AndconLab שהעברנו בכנס MultiScreenX בשנה שעברה.

הניעה (מוטיבציה) או: מה אני רוצה מכם?

גבירותי ורבותי, הימים של הטלפון נגמרו (למעשה הם מעולם לא היו קיימים), אנחנו חיים בעולם בו לרוב המוחלט של קהל המשתמשים שלכם יש מכשירים הנעים בין גדלים של 2 אינצ' (שעון חכם) עד 100 אינצ' (טלוויזיה ממש ממש ממש גדולה) ובין רזולוציות של מתחת QHD עד 4K.

בעבר (הרחוק) הייתם צריכים לבחור אחד מ-3 דרכים כדי להתמודד עם המצב הזה:

  1. להתעלם (ורובכם אכן בחרתם בדרך הזו).
  2. ליצור layout.xml נפרד לכל גודל ולנסות להבין בזמן ריצה במה להשתמש.
  3. ליצור אפליקציה נפרדת לכל גודל מסך ולהשתמש ב-Google Play ע"מ לנסות ולפלח את הקהל שלכם.

כל אחת משלושת הדרכים הגיעה עם רשימת חסרונות עצומה וכש-Google הבינה זאת (לפני שנתיים (!!!)) היא יצרה דרך טובה יותר, דרך זו נקראת Fragments והיא מאפשרת לכם בצורה פשוטה יחסית להתאים את עצמכם דינאמית לכל סוג מסך עם כמות מינימלית של פיתוח מצדכם ועם תקורת זכרון זמן ריצה מינימלית.

איך זה עובד?

מאז ומתמיד מערכת ההפעלה ידעה מהו גודל המסך ומה הרזולוציה שלו אבל החל מגרסת 3.0 (Honeycomb שהותאמה לטאבלטים בלבד) ומעלה היא משתמשת במידע הזה בצורה עמוקה הרבה יותר, עד הופעת ה-Fragments, יחידה בסיסית של Android שהיא ה-Activity וקובץ ה-layout.xml שלה הכילה מסך אחד בלבד ולכן מעבר בין מסכים יצר מעבר בין activities כך שלמרות שיכולת לשנות את ה-layout לפי גודל המסך אותו יודעת המערכת (ע"י שימוש בספריות ה-res המסומנות לפי גודל המסך או הרזולוציה שלו) הרי שעדיין כל "מסך" הכיל Activity אחת ויחידה. זה בדיוק מה שה-Fragments משנים. כיום, Activity יכולה להכיל מספר Fragments ואף להחליף Fragments בזמן ריצה בצורה חלקה ועם תקורת זכרון זמן ריצה נמוכה ביותר, בנוסף Activity יכולה להציג מספר Fragments במקביל ע"פ הגדרות שהוגדרו מראש (ב-.xml) או ע"י מניפולציה בזמן ריצה כך שתמיד תוכלו להשתמש ב"נדל"ן" של המסך שלכם בצורה הטובה ביותר כדי להעביר את כמות המידע הרצויה ללא שטחים מתים ובצורה רבגונית ומעניינת יותר כך שחווית המשתמש אותה אתם יכולים לייצר הינה טובה הרבה יותר וכמעט ללא צורך בפיתוח עצמי שלכם.

מצב כיום או: למה אתם עדיין לא משתמשים בזה?!

אזהרה: בפסקה הבאה אני אזרוק שם מפורסם שיגרום לכם לחשוב שאני אדם חשוב, אנא התעלמו מרמז נרקסיסטי זה :)

לא מזמן פגשתי את ה-developer advocate המפורסם, מר ריטו מאייר (reto meier) והוא טען שמהנדסים ומפתחים מסווגים כל חלק בפרויקט לאחד משני תאים:

  1. "ביצוע המשימה כנראה ימשך דקה".
  2. "ביצוע המשימה כנראה תמשך לנצח".

הוא המשיך וטען שרוב המפתחים ידחו את המשימות בתא #2 עד שאין משימות בתא #1, כמו-כן, כנראה שתמיד יהיו משימות בתא #1 ולכן לעולם לא תגיע למשימות בתא השני.

בנוסף הוא גרס שמהניסיון שלו ושל שאר ה-Google developer advocates החלוקה קרובה יותר להגדרות האלה:

  1. "המשימה תקח עד חצי שעה".
  2. "המשימה תקח יותר מחצי שעה".

כלומר כלל האצבע המשתמש הוא: שאם המשימה נראית כארוכה מחצי שעה היא לעולם לא תתבצע וחבל כי המשימות האלו הם אלו שישפרו את רוב המוצרים בסדר גודל ולא בצורה אינקרמנטלית ("בנקודות") כך שברוב המקרים, המשימות בתא #2 הם אלה שאדם הגיוני צריך לתת להן קדימות ועדיפות.

יכול להיות שאתם סיימתם לקרוא את הפסקה ואתם מהנהנים בראשכם לצדדים כמאמר: מה הוא מקשקש? אבל תודו שזה עורר את המחשבה שלכם אז… זה טוב.

שלב 0 – עם מה אנחנו מתחילים?

נתחיל עם הדוגמה הקלאסית של Activity המכילה רשימה שכל רכיב ברשימה הינו לחיץ ומוביל לרשימה נוספת.


public class MainActivity extends SherlockActivity implements OnItemClickListener{

	private ListView mList;
	private ProgressDialog mProgressDialog;
	private BroadcastReceiver mUpdateReceiver;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);

		setContentView(R.layout.main_activity);
		mList = (ListView) findViewById(R.id.list);

		mList.setOnItemClickListener(this);
	}

	@Override
	protected void onPause() {
		super.onPause();
		if (null != mUpdateReceiver) {
			unregisterReceiver(mUpdateReceiver);
			mUpdateReceiver = null;
		}
	}

	@Override
	protected void onResume() {
		super.onResume();
        mUpdateReceiver = new BroadcastReceiver() {
            //TODO: [Ran] handle network failure
            @Override
            public void onReceive(Context context, Intent intent) {
                if (intent.getAction().equalsIgnoreCase(CommunicationService.RESULTS_ARE_IN)) {
                    new lecturesLoader().execute((Void) null);
                }

                if (null != mProgressDialog)
                    mProgressDialog.dismiss();
            }
        };

        final IntentFilter filter = new IntentFilter();
        filter.addAction(CommunicationService.RESULTS_ARE_IN);
		registerReceiver(mUpdateReceiver, filter);

		new lecturesLoader().execute((Void)null);
	}

	@Override
	public void onItemClick(AdapterView list, View view, int position, long id) {
		Intent i = new Intent(this,SingleLectureActivity.class);
		i.putExtra(SingleLectureActivity.EXTRA_LECTURE_ID, id);
		startActivity(i);
	}

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getSupportMenuInflater().inflate(R.menu.main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.action_refresh:
                refreshList(true);
                return true;

            default:
                return super.onOptionsItemSelected(item);
        }
    }

	private void refreshList(boolean showProgressbar) {
		if (showProgressbar) {
			mProgressDialog = ProgressDialog.show(this, getString(R.string.progress_dialog_starting_title), getString(R.string.progress_dialog_starting_message));
		}

		Intent i = new Intent(this,CommunicationService.class);
		startService(i);
//		ServerCommunicationManager.getInstance(getApplicationContext()).startSearch("Android", 1);
	}

	////////////////////////////////
	// Async task that queries the DB in background
	////////////////////////////////
	private class lecturesLoader extends AsyncTask {
		private SQLiteDatabase db;

		@Override
		protected Cursor doInBackground(Void... params) {
			db = new DatabaseHelper(MainActivity.this.getApplicationContext(), DatabaseHelper.DB_NAME,null , DatabaseHelper.DB_VERSION).getReadableDatabase();
			return DBUtils.getAllLectures(db);
		}

		@Override
		protected void onPostExecute(Cursor result) {
			if (0 == result.getCount()) {
				//we don't have anything in our DB, force network refresh
				refreshList(true);
			}
			else {
				LecturesAdapter adapter = (LecturesAdapter) mList.getAdapter();
				if (null == adapter) {
					adapter = new LecturesAdapter(MainActivity.this.getApplicationContext(), result);
					mList.setAdapter(adapter);
				}
				else {
					adapter.changeCursor(result);
				}
			}
			db.close();
		}
	}
}

קובץ ה-layout הנקרא main_activity.xml נראה כך (מוכר ופשוט):


<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context=".EventsListActivity" >

    <ListView
        android:id="@+id/list"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:cacheColorHint="#000000"
        android:divider="@color/divider_color"
        android:dividerHeight="1dp"
        tools:listitem="@layout/event_list_item" >

    </ListView>

</RelativeLayout>

ומכאן נתחיל בדרך לשינוי…

שלב 1 – המעבר ל-FragmentActivity.

החלף שורה זו:

public class MainActivity extends SherlockActivity implements OnItemClickListener

בזו:

public class MainActivity extends SherlockFragmentActivity implements LecturesListFragment.callback{

שמתם לב ל-callback החדש במקום ה-OnItemClickListener? זה אומר שהמתודה המוכרת OnItemClick התחלפה בזה:


////////////////////////////
	// Fragment interface
	///////////////////////////
	@Override
	public void onLectureClicked(long lectureId) {
		if (mTwoPanes) {
			SingleLectureFragment f = new SingleLectureFragment();
			Bundle b = new Bundle();
			b.putLong(SingleLectureFragment.LECTURE_ID, lectureId);
			f.setArguments(b);
			getSupportFragmentManager().beginTransaction().replace(R.id.lecture_details_container, f).commit();
		}
		else {
			Intent i = new Intent(this,SingleLectureActivity.class);
			i.putExtra(SingleLectureActivity.EXTRA_LECTURE_ID, lectureId);
			startActivity(i);
		}
	}

	@Override
	public void fetchLecturesFromServer() {
		Intent i = new Intent(this,CommunicationService.class);
		startService(i);
		mProgressDialog = ProgressDialog.show(this, getString(R.string.progress_dialog_starting_title), getString(R.string.progress_dialog_starting_message));
	}

בואו נתמקד כרגע במתודה הראשונה ונראה שיש פה תנאי, התנאי עצמו הוא מימוש נאיבי מאוד לרעיון שיש מקומות שנרצה לכפות שהיה רק Fragment אחד מוצג למרות שיש "נדל"ן" המתאים ל-2 Fragments מוצגים במקביל אבל הוא גם בורר בין 2 אפשרויות שתמיד קיימות:

  • פתח Activity חדשה (כמו שהיה תמיד).
  • השתמש ב-FragmentManager והשתמש בפעולה בין 2 Fragments (במקרה הזה השתמשנו בפעולת ההחלפה).
  • זה למעשה לה הסיפור, החופש להחליף בין חלקים או לעבור בין Activities לפי מה שאתם רוצים.

    שלב 2 – יצירת ה-Fragment

    ה-LectureListFragment שמוזכר למעלה נראה כך:

    
    public class LecturesListFragment extends SherlockFragment implements OnItemClickListener{
    
    	private ListView mList;
    	private View mRootView;
    	private BroadcastReceiver mUpdateReceiver;
    	private callbacks mListener;
    
    	public interface callbacks {
    		public void onLectureClicked(long lectureId);
    		public void fetchLecturesFromServer();
    	}
    
    	@Override
    	public void onCreate(Bundle savedInstanceState) {
    		super.onCreate(savedInstanceState);
    
    		mUpdateReceiver = new BroadcastReceiver() {
    			//TODO: [Ran] handle network failure
    			@Override
    			public void onReceive(Context context, Intent intent) {
    				if (intent.getAction().equalsIgnoreCase(CommunicationService.RESULTS_ARE_IN)) {
    					reloadLecturesFromDb();
    				}
    			}
    		};
    	}
    
    	@Override
    	public View onCreateView(LayoutInflater inflater, ViewGroup container,
    			Bundle savedInstanceState) {
    
    		mRootView = inflater.inflate(R.layout.single_list_layout, null);
    		mList = (ListView) mRootView.findViewById(R.id.list);
    		mList.setOnItemClickListener(this);
    
    		reloadLecturesFromDb();
    
    		return mRootView;
    	}
    
    	@Override
    	public void onAttach(Activity activity) {
    		super.onAttach(activity);
    		if (!(activity instanceof callbacks)) {
    			throw new IllegalStateException("Activity must implement callback interface in order to use this fragment");
    		}
    		mListener = (callbacks) activity;
    	}
    
    	@Override
    	public void onPause() {
    		super.onPause();
    		if (null != mUpdateReceiver) {
    			getActivity().unregisterReceiver(mUpdateReceiver);
    		}
    	}
    
    	@Override
    	public void onResume() {
    		super.onResume();
    		if (null != mUpdateReceiver) {
    			IntentFilter filter = new IntentFilter();
    			filter.addAction(CommunicationService.RESULTS_ARE_IN);
    			getActivity().registerReceiver(mUpdateReceiver, filter);
    		}
    	}
    
    	@Override
    	public void onItemClick(AdapterView list, View view, int position, long id) {
    		if (null != mListener) {
    			mListener.onLectureClicked(id);
    		}
    	}
    
    	public void reloadLecturesFromDb() {
    		new lecturesLoader().execute((Void) null);
    	}
    
    	////////////////////////////////
    	// Async task that queries the DB in background
    	////////////////////////////////
    	private class lecturesLoader extends AsyncTask {
    		private SQLiteDatabase db;
    
    		@Override
    		protected Cursor doInBackground(Void... params) {
    			db = new DatabaseHelper(getActivity().getApplicationContext(), DatabaseHelper.DB_NAME,null , DatabaseHelper.DB_VERSION).getReadableDatabase();
    			return DBUtils.getAllLectures(db);
    		}
    
    		@Override
    		protected void onPostExecute(Cursor result) {
    			if (0 == result.getCount()) {
    				//we don't have anything in our DB, force network refresh
    				if (null != mListener) {
    					mListener.fetchLecturesFromServer();
    				}
    			}
    			else {
    				LecturesAdapter adapter = (LecturesAdapter) mList.getAdapter();
    				if (null == adapter) {
    					adapter = new LecturesAdapter(getActivity().getApplicationContext(), result);
    					mList.setAdapter(adapter);
    				}
    				else {
    					adapter.changeCursor(result);
    				}
    			}
    
    			db.close();
    		}
    	}
    
    }
    
    

    שימו לב שה-LectureLoader שהיה ב-MainActivity עבר לכאן ושיש לנו רשימה של אירועי life cycle חדשים שאפשר להשתמש בהם ע"מ לשמור, לעדכן ולבצע Caching למידע כרצוננו. ה-layout של ה-Fragment נראה כך (דומה מאוד ל-layout המקורי של ה-Activity) :

    
    
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingBottom="@dimen/activity_vertical_margin"
        android:paddingTop="@dimen/activity_vertical_margin"
        tools:context=".EventsListActivity" >
    
        <ListView
            android:id="@+id/list"
            android:layout_width="fill_parent"
            android:layout_height="fill_parent"
            android:cacheColorHint="#000000"
            android:divider="@color/divider_color"
            android:dividerHeight="1dp"
            tools:listitem="@layout/event_list_item" >
    
        </ListView>
    
    </RelativeLayout>

    השינוי הוא ב-layout של ה-Activity אשר כשהוא בברירת מחדל (תחת הספריה layout) כאשר הוא נראה כך:

    
    
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingBottom="@dimen/activity_vertical_margin"
        android:paddingTop="@dimen/activity_vertical_margin"
        android:background="@color/LecturesListBackground"
        tools:context=".EventsListActivity" >
    
        <fragment
            android:name="com.gdg.andconlab.ui.LecturesListFragment"
            android:layout_width="fill_parent"
            android:layout_height="fill_parent"
            android:id="@+id/lectures_fragment"
            />
    
    </RelativeLayout>

    אבל במקרה בו מסך המכשיר גדול מ-600dp (תחת הספריה בשם layout-sw600dp) נראה כך:

    
    
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal" >
    
        <fragment
            android:id="@+id/lectures_fragment"
            android:name="com.gdg.andconlab.ui.LecturesListFragment"
            android:layout_width="fill_parent"
            android:layout_height="fill_parent"
            android:layout_weight="3" />
    
        <LinearLayout
            android:id="@+id/lecture_details_container"
            android:layout_width="fill_parent"
            android:layout_height="fill_parent"
            android:layout_weight="2"
            >
    
        </LinearLayout>
    
    </LinearLayout>

    והנה לכם; כל המעבר ה"מפחיד" ל-Fragments נגמר. אני חושב שתסכימו איתי שזה לא מסובך ואני מקווה שהבנתם את היתרונות העצומים שתקבלו בחינם מזה. אז עד הפעם הבאה :)

    This post is also available in English on my personal blog here: http://royiby.blogspot.com/2013/11/one-code-fits-all-screens.html

    Royi is a Google Developer Expert for Android in 2013, a mentor at Google's CampusTLV for Android and (last but not least) the set top box team leader at Vidmind, an OTT TV and Video Platform Provider. www.vidmind.com

    FacebookTwitterGoogle+EmailPinterestWhatsAppLinkedInשתפו אותי

    Royi Benyossef

    I have been an Android developer since before the first Android phone existed and proceeded to work on development for Android based tablets and TVs long before they were launched by Google so you might say i'm somewhat Android pioneer whose expertise is in non-standard Android platforms and products which involve Android internals as well as application development. As an avid Open source enthusiast i have been Organizing events as well as blogging, speaking and mentoring on Android related topics since 2010 and it is due to the above that i am a Google developer expert since the program's inception which means that i'm not a Google employee but that Google believes that i know what i'm talking about as well as how to say it. Outside of Android i have been a full stack developer at a projects company where i was developing mobile applications in Android as well as iOS (Objective-c) and also dabbled in server side development in PHP, Jquery and more including a few projects of mobile applications using cross platform push technologies using socket.io, XMPP and other similar protocols as a server-side, i also had other wonderful projects which granted me experience in widgets, live wallpapers, network and battery efficiency and many more exciting topics.

    5 Comments

    1. dubelboom
      28/11/2013 בשעה 14:48

      לא הבנתי חצי מזה, אבל אני עדיין רק לומד 😉

      כל הכבוד!

    2. rock_artist
      29/11/2013 בשעה 11:26

      חשוב להדגיש רק שהקוד כאן מתייחס ל Sherlock שהיא ספריית תמיכה חלופית לזו המוצעת ע"י גוגל.
      http://actionbarsherlock.com/

      יש לה יתרונות רבים והתמיכה לאחור (2.3 לדוגמא) טובה משל גוגל.
      אך לפיתוח עבור ICS ומעלה לדוגמא אפשר להשתמש בקוד מערכת מובנה…

    3. royiby
      royiby
      01/12/2013 בשעה 07:11

      rock_artist אתה צודק באבחנה אבל קודם כל הלב של הפוסט היה השימוש ב-fragments שהינו זהה בין אם אתה משתמש ב-ABS או ב-actionbar הרגיל וחוצמזה שכל ה-developer advocates של Google ממליצים כרגע על השימוש ב-ABS ע"מ לתמוך במכשירים ישנים יותר.

    4. izik.avi
      01/12/2013 בשעה 10:05

      כתבה מעולה, היה שווה לחכות חודשיים

    5. rock_artist
      01/12/2013 בשעה 19:49

      עכשיו אני מפתח אפליקציה שכיוונתי ל 4.1 ומעלה אבל ככל שאני כותב יותר אני שוקל לעבור ל ABS בגלל שאני:
      – הקוד המובנה של גוגל מתעדכן פחות מה support library המקביל של גוגל עצמם.
      – אלמנטים שקיימים ב native לא קיימים ב support library אבל קיימים ב ABS.

      והמעבר בין הקוד כמו בדוגמה לזה של גוגל יחסית פשוט רק מומלץ לציין כולל לינק לאתר ABS :-)

    להגיב