※ RecyclerView

: RecyclerView는 흔히들 우리가 사용하는 ListView나 GridView와 비슷하나 더 쉽고 더 유연하고 효율적이다. RecyclerView는 화면에 보여줄 View들을 재활용하고 화면에 보여주는 역할만 한다. 재활용한다는 뜻은 예를 통해 알아보자. 액티비티가 RecyclerView로 구성되어 있고 RecyclerView가 100개의 리스트 항목 View들을 보여준다고 가정하자. 휴대폰에서 해당 액티비티를 시작했을 때 휴대폰 화면 크기상 리스트 항목이 12개 밖에 안 보인다고 하면 사용자가 액티비티를 위 아래로 스크롤 할 시 나머지 리스트 항목이 보여질 것이다. 그렇다면 RecyclerView는 리스트 항목 100개에 대해 각각의 객체를 만들까? 그렇지 않다. RecyclerView는 리스트 항목 객체를 재활용한다. 리스트 항목 각각의 객체를 만드는 게 아닌 한 화면을 채우는 리스트 항목의 갯수만큼 객체를 만든다. 즉 100개가 아닌 12개를 만드는 것이다. 그리고 사용자가 화면을 스크롤할 때 만들어진 12개의 객체를 다시 사용하는 것이다. 이런 역할을 RecyclerView가 한다. 앞에서 말했듯이 RecyclerView는 리스트 항목 View들을 재활용하고 화면에 보여주는 역할만 한다. 따라서 RecyclerView를 제대로 구현하기 위해서, 리스트 항목 View를 만들고 값을 대입하는 등의 기능을 수행하기 위해서 Adapter와 ViewHolder 클래스에 대해서 알아야한다. 이에 대해서 알아보자.  


※ ViewHolder

:ViewHolder가 하는 일은 리스트 항목 하나의 View를 만들고 보존하는 일을 한다. 리스트 항목이 하나의 ImageView와 TextView로 구성되어 있다고 하면 다음과 같이 코드를 작성할 수 있다.

public class RecyclerHolder extends RecyclerView.ViewHolder{
	private TextView textView;
	private ImageView imageView;

	public RecyclerHolder(View view){
		super(view);
		textView = (TextView)view.findViewById(R.id.~);
		imageView = (ImageView)view.findViewById(R.id.~);
	}
}

RecyclerView.ViewHolder을 상속받는 클래스를 작성한다. 그리고 리스트 항목 하나의 View를 생성자에서 만든다. 생성자의 매개변수는 리스트 항목에 대한 레이아웃이다. 위와 같이 RecyclerView는 자신이 View 객체를 생성하지 않는다. Adapter를 통해서 ViewHolder 객체를 생성한다. ViewHolder 클래스 내부에는 itemView라는 필드가 있는 데 해당 필드로 View 객체를 가져온다. 포스트 앞 부분에서 RecyclerView에서 12개의 리스트 항목 View가 생성된다고 했는 데 실제 ViewHolder가 12개 생성되는 것이다. ViewHolder가 하는 일은 이게 끝이다. 리스트 항목 하나의 View를 만들고 보존한다.


※ Adapter

: 다시 말하지만 RecyclerView는 단지 RecyclerView 항목들을 재활용하고 보여주는 역할만 한다. 즉 자신이 ViewHolder을 생성하지 않는다. Adpater에게 그 일을 요청한다. Adapter는 필요한 ViewHolder 객체를 생성하고 데이터를 ViewHolder 객체와 결합하는 역할을 한다. 이에 대해 구체적으로 살펴보자. RecyclerView는 화면에 보여질 때 Adapter와 소통을 한다. 먼저 RecyclerView는 우리가 구현할 Adapter의 getItemCount() 메서드를 호출해서 RecyclerView에서 보여줘야하는 리스트 항목 총 갯수를 요청한다. 포스트 앞 부분의 예에서 전체 리스트 항목 100을 Adpater에게 요청하는 것이다. 그 다음에 RecyclerView는 Adapter의 onCreateViewHolder(ViewGroup, int) 메서드를 호출한다. onCreateViewHolder 메서드도 우리가 뒷 부분에서 구현할 예정이다. onCreateviewHolder가 실행되면 RecyclerView는 Adapter에게 ViewHolder 객체를 받는다. 그리고 RecyclerView는 onBindViewholder(ViewHolder, int)를 호출하면서 전에 Adapter에게 받았던 ViewHolder 객체와 리스트에서 해당 ViewHolder의 위치를 인자로 전달한다. Adapter는 인자로 받은 위치에 맞는 데이터를 찾은 후 그 것을 ViewHolder의 View에 결합하게 된다. 이 과정이 끝나면 RecyclerView는 하나의 리스트 항목 View를 화면에 위치시킨다. 즉 포스트 앞 부분 예제에서 12번 위 과정을 거치면 화면에 꽉 찰 리스트 항목 View들이 다 생성되는 것이다. 그 다음부터는 RecyclerView는 onCreateView를 호출하지 않는다. 기존에 생성된 ViewHolder 객체를 재사용하기 때문이다. 이처럼 RecyclerView는 항목 View들을 재활용시켜 시간과 메모리를 절약한다.  


※ RecyclerView 구현하기

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                Bundle savedInstanceState) {
	.....

	mRecyclerView = (RecyclerView)view.findViewById(R.id.main_recycler_view);
	mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));

	.....
}

프래그먼트에 RecyclerView 객체를 만들고 setLayoutManager을 호출한다. RecyclerView는 직접 화면에 리스트 항목들을 위치시키진 않는다. LayoutManager에게 해당 작업을 위임한다. LayoutManager는 리스트 항복 View들을 화면에 위치시키고 스크롤 동작을 처리한다. 따라서 무조건 setLayoutManager을 사용해야 한다. 인자로 LinearLayoutManager가 들어가면 ListView와 같이 보이고 GridLayoutManager을 전달하면 GridView처럼 보인다. StaggeredGridLayoutManager은 크기가 일정하지 않은 아이템을 격자 형태로 보여준다. 참고로 RecyclerView 자체 크기가 변하지 않을 때는 setHasFixedSize(ture)를 호출하면 성능이 향상된다. 다음은 Adapter 클래스이다.

class RecyclerAdapter extends RecyclerView.Adapter<RecyclerData> {
        private List<RecyclerData> mRecyclerData;

        public CrimeAdapter(List<RecyclerData> RecyclerData) {
            mRecyclerData = RecyclerData;
        }

        @Override
        public RecyclerDataHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            LayoutInflater layoutInflater = LayoutInflater.from(getActivity());
            View view = layoutInflater
                    .inflate(R.layout.list_item_data, parent, false);
            return new RecyclerHolder(view);
        }

        @Override
        public void onBindViewHolder(CrimeHolder holder, int position) {
            RecyclerData recyclerData = mRecyclerData.get(position);
            holder.bindRecyclerData(recyclerData);
        }

        @Override
        public int getItemCount() {
            return mCrimes.size();
        }
}

위 코드를 보면 실제로 onCreateViewHolder에서 ViewHolder 객체를 생성하는 걸 볼 수 있다. onBindViewHolder 메서드를 보면 RecyclerView에서 받은 위치 값을 토대로 알맞은 데이터를 찾은 후 holder의 bindRecyclerData를 호출한다. 이 메서드는 다음 코드에서 볼 예정인데 ViewHolder 안 View에 데이터를 넣어주는 역할을 한다. getItemCount 메서드는 전체 리스트 항목의 갯수를 반환한다. 

private class RecyclerHolder extends RecyclerView.ViewHolder
            implements View.OnClickListener {
        private RecyclderData mRecyclderData;
        private TextView mTitleTextView;
        private TextView mDateTextView;
        private CheckBox mSolvedCheckBox;

        public RecyclerHolder(View itemView) {
            super(itemView);
            itemView.setOnClickListener(this);

            mTitleTextView = (TextView)
                    itemView.findViewById(R.id.list_item_recycler_title_text_view);
            mDateTextView = (TextView)
                    itemView.findViewById(R.id.list_item_recycler_date_text_view);
            mSolvedCheckBox = (CheckBox)
                    itemView.findViewById(R.id.list_item_recycler_solved_check_box);

        }

        public void bindRecyclderData(RecyclderData recyclderData) {
            mRecyclderData = recyclderData;
            mTitleTextView.setText(mRecyclderData.getTitle());
            mDateTextView.setText(mRecyclderData.getDate().toString());
            mSolvedCheckBox.setChecked(mRecyclderData.isSolved());
        }

        @Override
        public void onClick(View v) {
            Toast.makeText(getActivity(),
                    RecyclerData.getTitle() + " 선택됨!", Toast.LENGTH_SHORT)
                    .show();
        }
}
그리고 마지막으로 RecyclerView와 Adpater, 리스트 항목에 들어갈 데이터를 연결해주는 작업만 하면 된다.


    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_main_list, container, false);

        mRecyclerView = (RecyclerView) view
                .findViewById(R.id.main_recycler_view);
        mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));

        updateUI();

        return view;
    }

    @Override
    public void onResume(){
        super.onResume();
	updateUI();
    }

    private void updateUI() {
        List<RecyclerData> recyclerDatas = mRecyclerDatas

	if(mAdapter == null){
 	       mAdapter = new RecyclerAdapter(recyclerDatas);
 	       mRecyclerView.setAdapter(mAdapter);
	}else{
		mAdapter.notifyDataSetChanged();
	}
    }

updateUI 메서드를 보면 notifyDataSetChanged() 메서드가 있다. 해당 메서드는 리스트 항목 뷰에 들어간 데이터가 바꼈을 때 호출한다. 만약 현재 화면에 보이는 리스트 항목 View 한 개의 데이터가 바뀌어 다른 데이터를 출력해야 한다고 하면 notifyDataSetChanged() 메서드를 호출하면 된다. 그래서 onResume() 에서 updateUI를 호출한다. 다른 액티비티에서 데이터가 변경될 수 있기 때문이다. 그런데 onStart()가 아닌 onResume()에서 호출하는 이유는 다른 액티비티로 화면이 이동하고 다시 돌아오는 게 아닌 현재 RecyclerView 위에 투명 액티비티가 올라오면 일시정지가 될 수 있기 때문이다. 그러면 onStart는 호출되지 않을 것이다. 따라서 onResume()에서 호출했다. 추가로 notifyDataSetChanged()는 리스트 전체 데이터에 대해서 반응한다. 따라서 별도로 하나의 리스트 항목 View에 대해서 notifyDataSetChanged(int)를 구현하면 더 효율적일 것이다. 



- RecyclerView 구분선 표시하기

: RecyclerView에 구분선을 표시하기 위해서는 RecyclerView.ItemDecoration 클래스를 상속해 onDraw 메서드를 오버라이딩 하면 된다. getItemOffsets() 메서드로 각 아이템에 대한 Offset 빈 영역을 설정하고 onDraw()로 실제 구분선을 그린다. 아래와 같이 코딩한 후 recyclerView.addItemDecoration(new DividerItemDecoration(this));를 호출하면 된다.

public class DividerItemDecoration extends RecyclerView.ItemDecoration {

private final int dividerHeight;
private Drawable divider;

public DividerItemDecoration(Context context) {
// 기본인 ListView 구분선의 Drawable을 얻는다(구분선을 커스터마이징하고 싶을 때는 여기서 Drawable을 가져온다)
final TypedArray a = context.obtainStyledAttributes(new int[]{android.R.attr.listDivider});
divider = a.getDrawable(0);

// 표시할 때마다 높이를 가져오지 않아도 되게 여기서 구해 둔다
dividerHeight = divider.getIntrinsicHeight();
a.recycle();
}

// View의 아이템보다 위에 그리고 싶을 때는 이쪽 메소드를 사용한다
// @Override
// public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
// super.onDrawOver(c, parent, state);
// }


// View의 아이템보다 아래에 그리고 싶을 때는 이쪽 메소드를 사용한다
// 여기서는 RecyclerView의 아이템마다 아래에 선을 그린다
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDraw(c, parent, state);
// 좌우의 padding으로 선의 right과 left를 설정
final int lineLeft = parent.getPaddingLeft();
final int lineRight = parent.getWidth() - parent.getPaddingRight();

final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams();

// 애니메이션 등일 때에 제대로 이동하기 위해서
int childTransitionY = Math.round(ViewCompat.getTranslationY(child));
final int top = child.getBottom() + params.bottomMargin + childTransitionY;
final int bottom = top + dividerHeight;

// View 아래에 선을 그린다
divider.setBounds(lineLeft, top, lineRight, bottom);
divider.draw(c);
}
}

@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
// View 아래에 구분선이 들어가므로 아래에 Offset을 넣는다
outRect.set(0, 0, 0, dividerHeight);
}
}


: 스마트폰을 회전시키면 장치 구성(device configuration)이라는 게 변경된다. 장치 구성은 장치의 현 상태를 나타내는 집합이다. 예로 화면 방향, 화면 밀도, 화면 크기, 키보드 타입, 언어 등이 있다. 장치 구성이 변경되면 애플리케이션은 변경된 장치 구성에 가장 맞는 리소스로 변경된다. 예로 화면 밀도를 보자. 만약 특정 버튼에 이미지를 적용한다고 하면 화면 밀도마다 drawable-mdpi(중밀도), drawable-hdpi(고밀도) 등에 맞는 이미지를 만든다. 애플리케이션은 자신의 장치 구성에 맞는 화면 밀도의 이미지를 찾아 보여줄 것이다. 이렇게 안드로이드 화면이 가로/세로 방향으로 변경된다면, 즉 장치 구성이 변경된다면 안드로이드는 그 것에 맞는 리소스를 찾고 해당 리소스를 적용하게 된다. 


※ 가로 방향 레이아웃 생성

: 실제 가로 방향 레이아웃을 생성해보자. 안드로이드 프로젝트에서 res 디렉터리에 오른쪽 마우스 버튼을 클릭하고 New -> Android resource directory를 들어간다. Resource Type 드롭다운에서 layout을 선택하고 Available qualifiers 항목에서 Orientation을 선택한 후 >>를 누른다. 그러면 Screen orientation 드롭다운이 나오는데 거기서 Landscape를 선택한다. 

그리고 OK 버튼을 누르면 res/layout-land 디렉터리가 생성된다. -land는 -mdpi,-hdpi 같은 구성 수식자이다. 구성 수식자는 안드로이드가 현재의 장치 구성에 가장 잘 맞는 리소스들을 식별하는 방법이다. 장치가 가로 방향으로 바뀌었을 때 안드로이드는 res/layout-land 디렉터리의 리소스를 사용하며 해당 디렉터리가 없다면 res/layout 디폴트 리소스를 사용한다. 만약 메인 액티비티의 layout을 activity_main.xml이라고 하자. 메인 액티비티가 가로 방향으로 바뀌었을 시 안드로이드는 res/layout-land 디렉터리를 찾고, 있다면 거기에 있는 activity_main.xml을 적용한다. res/layout-land 디렉터리가 없다면 res/layout 디렉터리의 activity_main.xml을 적용한다. 이 과정에서 레이아웃만 새로 바뀌는 게 아니다. 액티비티도 새로운 인스턴스로 생성되어 시작한다. 서로 다른 레이아웃을 보여주기 위해서는 onCreate() 메서드 안에서 setContentView 메서드가 다시 호출되야 하기 때문이다. 따라서 안드로이드 화면 방향이 변경되면 기존의 액티비티 인스톤스는 소멸되고 새로운 액티비티 인스턴스가 생성되 onCreate()의 setContentView에서 가장 적합한 리소스에 따라 액티비티가 그려지게 된다. 이렇게 런타임 시 장치 구성(화면 방향, 언어, 키보드) 변경이 생기면 현재의 액티비티는 소멸되고 새로운 액티비티를 생성한다. 


※ 가로/세로 화면 변경 시 데이터 저장

: 만약 1초에 한 번씩 0부터 1씩 더해지는 count 변수가 있고 count 변수가 메인 액티비티에 출력된다고 가정하자. count가 10일 때 화면이 가로 방향으로 변경되면 count 값은 0에서 다시 출력된다. 화면이 가로 방향으로 변화는 장치 구성의 변경이기 때문에 기존의 메인 액티비티 인스턴스는 사라지게 된다. 따라서 count 변수도 사라지고 새로운 액티비티가 생성되 count는 0부터 다시 시작하게 된다. 이러한 결함을 바로 잡으려면 화면 구성이 변경되기 전의 count 값을 별도의 공간에 저장하다가 새로운 액티비티 인스턴스가 생성되면 count 값을 복구해야 한다. 이를 해주는 게 다음 함수이다.

@Override protected void onSaveInstanceState(Bundle saveInstanceState)

Bundle 객체는 문자열 키와 특정 타입의 값 한 쌍으로 데이터를 저장하는 객체이다. 저장할 데이터를 saveInstanceState.putInt("count", count); 형식으로 저장할 수 있다. Bundle 객체에 저장하거나 읽을 수 있는 타입은 기본형 데이터 타입이나 Serializable 또는 Parcelable 인스턴스를 구현하는 객체여야 한다. 위 메서드는 onPause(), onStop(), onDestroy()가 호출되기 전에 호출된다. 따라서 화면 방향이 가로로 변경되면 위 메서드가 콜백되고 count 값을 저장하면 된다. 다음으로 count 값 복구는 아래와 같이 하면 된다.

@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (savedInstanceState != null) { count = savedInstanceState.getInt("count", 0); } }

복구할 때는 onCreate 메서드에서 번들 객체에 저장된 값을 확인하면 된다. 화면 방향이 변경되면 새로운 액티비티 인스턴스가 생성되므로 onCreate 메서드가 호출된다. onCreate 메서드의 매개변수인 번들 객체 내부에는 우리가 onSaveInstatceState에서 값을 저장하는 데 사용한 번들 객체가 전달된다. 따라서 onCreate 매개변수 번들 객체에서 저장했던 count 값을 얻을 수 있다. 


※ onSaveInstanceState(Bundle)의 다른 용도

: 안드로이드는 메모리 회수를 해야 한다. 일정 시간동안 장치를 사용하지 않으면 액티비티가 소멸될 수 있다. 물론, 실행 중인 액티비티는 절대로 회수하지 않는다. 일시 정지(Pause), 중단(Stopped) 상태에 있는 액티비티만 회수한다. 따라서 액티비티가 일시 정지/중단 되면 액티비티는 onSaveInstanceState를 호출한다. 그리고 Bundle 객체는 안드로이드 운영체제에 의해 액티비티 레코드에 기록된다. 액티비티가 일시 정지/중단이 되면 보존(stashed) 상태가 된다. 보존 상태에서는 액티비티 객체는 존재하지 않고 액티비티 레코드만 존재하게 된다. 안드로이드 운영체제는 보존 상태의 액티비티를 액티비티 레코드를 사용하여 되살린다. 이 때 Bundle 객체를 참조하여 데이터를 복구할 수 있다. 참고로 액티비티 레코드는 사용자가 Back 버튼을 누를 때 액티비티가 완전히 소멸되는 데 그 때 액티비티 레코드도 같이 소멸된다.


* onSaveInstanceState는 사용자가 Back 키로 액티비티를 명시적으로 폐기한 경우 호출이 안 된다. 영속적으로 저장하고 싶으면 onPause에서 저장해라.

+ Recent posts