실시간 데이터 ListView 구현 :: 소림사의 홍반장!

실시간 데이터 ListView 구현

2013. 1. 8. 13:55 - 삘쏘굿

출처 : 마이크로소프트웨어 (http://www.imaso.co.kr/?doc=bbs/gnuboard.php&bo_table=article&wr_id=37390)안드로이드 개발을 하다 보면 데이터를 동적으로 보여줘야 할 경우가 많다. 물론 서버에 저장돼 있는 데이터를 불러와 스마트폰에서 보여주는 방법은 간단하다. 하지만 데이터의 수가 많아지거나 사이즈가 커서 불러오는 데 오랜 시간이 걸린다면 사용자는 분명 답답함을 느낄 것이다. 심할 경우 애플리케이션을 지워버릴지도 모른다. 이번 호에서는 AsyncTask를 사용해 데이터를 동적으로 받아오는 방법을 알아본다. 그것을 ListView 등에 적용해 사용자에게 조금 더 나은 애플리케이션을 제공해 보자.

 

요즘은 서버와의 연동 형식을 취하는 애플리케이션이 증가하고 있다. 이러한 애플리케이션은 크게 두 가지 과정을 거친다.

1. 서버에 데이터를 요청해 받음
2. 받은 데이터를 화면에 나타냄

하지만 이런 네트워크 작업들을 수행하는 데 있어서 딜레이가 생기게 마련이다. 여기서 ‘이 딜레이를 어떻게 해결할까?’라는 고민에 빠지게 된다. 네트워크 작업은 시간이 어느 정도 걸릴지 예측하기 힘들기 때문에 애플리케이션이 더디게 작동할 수도, 멈춰버릴 수도 있다. 빠르게 반응하는 애플리케이션을 만들기 위해서는 이같은 딜레이를 잘 관리해야 한다. 대부분의 애플리케이션은 해결방안으로 별도의 스레드에서 네트워크 작업을 수행해 메인 스레드는 최대한으로 가볍게 수행되도록 한다. 하지만 가장 중요한 점은 ‘네트워크 작업을 하는 동안 사용자에게 무엇을 보여줄 것인가’다. 필자는 보통 다음과 같이 두 가지 방법을 사용한다.

1. 프로그래스 바를 사용해 프로그램이 멈춰있는 것이 아니라 사용자에게 로딩중임을 알리는 방법 
2. 빨리 받아올 수 있는 데이터는 먼저 보여주고 시간이 걸리는 데이터는 사용자가 앞의 데이터를 보는 동안 가져와서 보여주는 방법

두 개의 방식을 혼합해서 자주 사용한다. 애플리케이션의 경우는 크게 서버에서 받아오는 데이터를 텍스트와 이미지로 구분할 수 있다. 텍스트는 받아오는 데 오랜 시간이 걸리지 않지만 이미지는 경우에 따라 많은 시간이 걸리기도 한다. 때문에 액티비티가 시작되면 프로그레스 바를 띄워 백그라운드에서 텍스트 데이터를 받아와 사용자에게 먼저 보여주고 텍스트를 보는 동안 이미지를 받아와서 나타내는 방법을 취한다.  

<화면 1> 서버연동 형식의 애플리케이션

이러한 방법을 사용하기 위해서는 백그라운드 작업이 중요하다. 백그라운드 작업은 기존과 같이 별도의 스레드를 이용해 수행한다. 하지만 받아 온 데이터를 화면에 나타내는 작업을 별도의 스레드를 생성해 수행한다면 다음과 같은 에러 메시지가 나타난다. 

“android.view.ViewRoot$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.”

별도의 스레드에서 UI 관련 업데이트를 시도하는 경우 View를 생성한 오리지널 스레드에서 접근할 수 있다는 메시지다. 예를 들어, 텍스트 데이터를 TextView에 나타내기 위해 Text View.setText()와 같은 함수를 사용한다면 UI를 업데이트하기 위한 작업은 그 View 구조를 생성한 스레드에서만 가능하기 때문에 별도의 스레드가 아닌 메인 스레드에 접근해 수행해야 한다. 이같은 이유 때문에 UI 업데이트를 위해 보통은 Handle, Looper, Post 등을 사용하지만 메인 스레드와 자주 통신하면 코드가 복잡해질뿐만 아니라 관리하기도 불편해진다. 특히 ListView나 GridView와 같이 여러 개의 데이터를 동시다발적으로 보여줘야 할 경우에는 관리가 더 힘들어진다. 안드로이드는 이러한 작업을 조금 더 간단하게 처리할 수 있도록 AsyncTask라는 클래스를 제공한다.

AsyncTask의 이해

<화면 2> AsyncTask클래스

AsyncTask는 안드로이드에서 백그라운드 작업의 대부분을 추상화해 백그라운드 작업의 관리를 조금 더 편하게 하기 위해 만들어진 클래스다. 작업이 비동기적으로 수행되고 백그라운드 작업뿐만 아니라 Handler, Looper, Post 등을 사용하지 않고도 메인 스레드에 접근이 가능하다. 필자는 주로 AsyncTask를 사용해 백그라운드 작업을 수행한다. 

<화면 3> 프로젝트 생성 창

서버에서 책의 정보 데이터를 받아 ListView에 나타내는 구조를 모듈로 구분해서 살펴보자. 

프로젝트 생성
안드로이드 프로젝트를 생성하자. <화면 3>은 이해를 돕기 위해 정보를 기입한 화면이다.

레이아웃 파일 구성
DataListActivity.java의 화면을 구성할 XML 레이아웃 파일을 작성해 보자.

<리스트 1> main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android. com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<ListView
android:id="@+id/list"  
android:layout_width="match_parent" 
android:layout_height="match_parent" 
/>
</LinearLayout>


List들을 보여주는 액티비티인 DataListActivity의 UI를 구성하는 main.xml 파일이다. 책의 정보들을 서버에서 받아와 List 형식으로 보여주기 위해 다른 위젯들은 구성하지 않고 ListView만 구성했다. ListView를 구성할 때 주의해야 할 것은 width와 height 속성값을 ‘wrap_contents’로 설정하지 않는 것이 좋다는 것이다. ListView의 크기가 얼마가 될지 아무도 모르기 때문이다. 이제 각 List의 한 행의 UI를 구성해 보자.

<리스트 2> list_row.xml

<?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="wrap_content"  
android:orientation="horizontal"
android:background="#ffffff" 
> 
   <ImageView
   android:id="@+id/rowImage"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:background="@drawable/defaultimage"
   />
   <LinearLayout
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:orientation="vertical"
   android:layout_gravity="center_vertical"
   >
      <TextView
      android:id="@+id/rowAuthor"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:textSize="15sp"  
      android:textColor="#959595"
      />
      <TextView
      android:id="@+id/rowTitle"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:textSize="26sp"  
      android:textColor="#000000"  
      />
   </LinearLayout>
   <ImageView
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:background="@drawable/arrow"
   android:layout_gravity="center_vertical"
   />
</LinearLayout>


list_row.xml 레이아웃 파일은 ListView의 각 행을 구성할 레이아웃이다. 하나의 ImageView와 두 개의 TextView로 이뤄져 있다. 

서버에서 받은 데이터 저장
이제 서버에서 하나의 이미지 파일과 두 개의 텍스트 정보를 받아서 각각의 특성에 맞는 위젯에 연결할 것이다. 각각의 애플리케이션마다 다르겠지만 필자의 애플리케이션에서는 여행지의 대표 이미지 하나와 이름, 장소의 정보만이 필요하다. 서버에서 데이터를 저장할 클래스를 작성해 보자(<이달의 디스켓>의 <리스트 1> 참조). 

서버에서 여행지 이름, 장소, 여행지 이미지가 있는 URL을 받아와 저장한 후에 이미지를 동적으로 받아와서 사용자에게 보여줄 것이다. 

AsyncTask 이용해 텍스트 데이터 받아오기
본격적으로 서버에서 동적으로 데이터를 받아올 클래스를 작성해 보자. 텍스트 데이터와 이미지 데이터를 받아올 클래스를 각각 작성해 액티비티가 처음 호출되면 텍스트 데이터를 받은 후 사용자에게 보여주는 동안 이미지 데이터를 받아오도록 구성한다.

<리스트 3> TextTask.java

public class TextTask extends AsyncTask<String, Integer, ArrayList<WayPoint>> {
 
   private DB dbCon;
   private DataAdapter adapter;
   publicTextTask(DBdbCon,DataAdapteradapter) {……………………………(1)
      super();
      this.dbCon =dbCon; 
      this.adapter=adapter;
   }
 
   protectedArrayList<WayPoint>doInBackground (String...params) {………(2)            
      adapter.setObjects(dbCon.getWayPointList());
      return null;
   }
 
   @Override
   protected void onPostExecute(ArrayList<WayPoint> result) { ……………(3)
                        
   adapter.notifyDataSetChanged();
            
   }
}


 TextTask.java는 텍스트 데이터를 받아오기 위해 만들어진 클래스다. 앞서 설명했던 AsyncTask 클래스를 상속받아서 이용한다. AsyncTask 클래스는 여러 가지 메소드를 제공하므로 각각의 목적에 맞게 사용하면 된다. (1)생성자에서는 다른 메소드에서 사용하기 위해 Adapter와 DB 클래스의 인스턴스를 인자로 받아온다. 일반적으로 AsyncTask가 먼저 시작되면 onPre Execute() → doInBackground() → PostExecute() 순으로 호출된다. 각각의 메소드의 특징을 살펴보면 다음과 같다. 

● protected void onPreExecute()
AsyncTask에 의해 가장 먼저 호출되는 콜백 메소드다. 메인 스레드 안에서 실행되기 때문에 주로 초기화나 프로그레스, 다이얼로그 등의 준비 작업을 한다.

● protected String doInBackground()
별도의 스레드에서 실행되는 메소드로서 UI 변경을 제외한 여러 가지 실제 작업들을 처리한다. 주기적으로 publishProgress()를 호출해 작업 진행률을 알려줄 수 있다.

● protected void PostExecute()
doInBackground()가 끝나면 호출되는 메소드로서 주로 실제 작업한 내용을 바탕으로 UI 업데이트 작업을 한다.

<리스트 4> ImageTask.java

public class ImageTask extends AsyncTask<Integer, Integer, Integer> {
   private DataAdapter adapter;    
    
   public ImageTask(DataAdapter adapter) {
      this.adapter = adapter;
   }
   @Override
   protected Integer doInBackground(Integer... params) {
        
      downloadBitmap(params[0]); ………………………(1)
      return null;
   }
   @Override
   protected void onPostExecute(Integer result) {
        
      adapter.notifyDataSetChanged();
   }
   public void downloadBitmap(int position)
   {
      InputStream bis;
      try {            
         bis=newjava.net.URL(adapter.getObjects().get (position).getImgSrc()).openStream();
         BitmapFactory.Options options = new BitmapFactory.Options();
         options.inSampleSize = 4; …………………(2)
         Bitmap bm = BitmapFactory.decodeStream(new FlushedInputStream(bis),null,options);
         adapter.getObjects().get(position).setBitmap(bm);
         bis.close();
            
      } catch (MalformedURLException e) {
         // TODO Auto-generated catch block
         e.printStackTrace();
      } catch (IOException e) {
         // TODO Auto-generated catch block
         e.printStackTrace();
      }
   }    
}

 TextTask는 onPreExecute()를 사용하지 않으므로 (2)에서와 같이 서버에서 getWayPointList()로 각각의 여행지 이름과 장소, 이미지의 경로를 받아오게 된다. 
그 후  PostExecute()를 통해 adapter에게 데이터가 변경됐음을 알려서 ListView를 업데이트한다. 이제 받아 온 이미지 경로를 통해 이미지를 동적으로 받아와 비트맵 형식으로 변경한 후 WayPoint 클래스에 저장해 보자. 

AsyncTask 이용해 이미지 데이터 받아오기
마찬가지로 AsyncTask를 이용해 받아 온 이미지 URL을 비트맵 형식으로 변경해 WayPoint에 저장하는 작업을 할 것이다. 하지만 TextTask와 달리 이미지를 받는 데는 여러 가지 신경써야 할 부분들이 존재한다. 이 점에 유의하면서 <리스트 4>를 보자.

TextTask와 ImageTask의 구조는 비슷하지만 doInBack ground에서 하는 작업이 다르다. TestTask가 서버에서 텍스트 데이터를 받아오는 과정이라면 ImageTask는 받아 온 이미지 데이터 URL을 사용해 실제 이미지를 받아온다. <리스트 4>의 (1)에서와 같이 downloadbitmap()에 params[0]이라는 값을 넘겨준다. 이 값은 나중에 Obejct의 position을 나타내는 것이고 ImageTask를 execute() 할 때 인자값 배열 중 가장 첫 번째에 있는 값이 된다. 실제 이미지를 받는 부분을 살펴보자. position을 이용해 adapter 안의 Object 이미지 URL을 가져와서 InputStream을 열고 BitmapFactory 클래스를 사용해 데이터를 비트맵으로 디코딩한다. decodeStream()은 InputStream에서 비트맵을 만드는 역할을 한다. 여기서 가장 중요한 점은 이미지를 리사이징하는 부분이다. 서버에 저장돼 있는 이미지의 사이즈가 2,048×1,536이라고 생각해 보자. 하나의 이미지를 비트맵으로 만드는 것은 무리가 없지만 여러 장의 비트맵을 만들다 보면 다음과 같은 에러가 발생한다.

java.lang.OutOfMemoryEerror: bitmap size exceeds VM budget

할당된 힙 메모리의 사이즈가 모바일 디바이스에서는 작아서 Leak이 자주 일어난다. OutOfMemory를 해결하기 위해서는 이미지 리사이징이 필수다. 비트맵 클래스에서는 createScaled Bitmap를 사용해 손쉽게 사이즈를 변경할 수 있지만 이미지의 사이즈가 엄청 크다면 문제가 생길 수 있다. 앞에서 말한 방법은 먼저 비트맵으로 변경한 후 리사이징하기 때문에 OutOf Memory를 해결하기에는 조금 부족하다. 이럴 경우를 대비해 비트맵Factory 클래스의 옵션이 존재한다. BitmapFactory. decodeStream에 대해 찾아보면 오버로딩돼 있는 것을 알 수 있다. BitmapFactory,Options을 인자로 받느냐 받지 않느냐의 차이인데 이 옵션을 사용해서 여러 가지 설정을 할 수 있다. inSampleSize는 디코딩할 때 얼마의 비율로 줄일 것인지를 설정하는 옵션이다. 

<리스트 4>와 마찬가지로 4로 설정하면 이미지의 크기가 1/4로 줄여져서 디코딩된다. 화면 전체에 이미지가 보여져야하는 경우에는 1/4 크기로 이미지가 줄면 문제가 생기겠지만 ListView에서 사용하는 것과 같이 섬네일 정도의 용도라면 inSampleSize를 적용해 디코딩하는 것이 더욱 효과적이고 안정적일 것이다. 이렇게 디코딩된 비트맵을 Adapter의 Object에 저장하고 작업을 끝낸다. onPostExecute()에서는 TextTask와 마찬가지로 데이터가 업데이트됐으므로 notifyDataSetChanged()를 호출해 준다.

decodeStream 버그
안드로이드 개발자 사이트(http://developer.android.com/)의 블로그를 보면 프로요(2.2) 이하 버전에서는 Bitmap Factory.decodeStream의 네트워크 커넥션이 느린 경우에는 제대로 작동하지 않는 버그가 존재한다고 나와 있다. 따라서 이 버그를 수정하기 위해 FlushedInputStream 클래스를 작성, inputStream을 새로 생성해 사용하도록 권장한다(<이달의 디스켓>의 <리스트 2> 참조).

FlushedInputStream 클래스는 Stream이 끝나지 않는 한 skip()가 실제로 전달받은 바이트 수만큼 건너뛰도록 구현돼 있다고 나와 있다.

CustomAdapter를 이용한 데이터 매칭 작업
각각의 데이터를 다운로드하고 저장할 클래스들을 모두 완성했으므로 이제는 BaseAdapter를 상속받은 CustomAdapter를 사용해 각각의 데이터를 매칭시킬 차례다. 

<리스트 5> DataListActivity.java

public class DataAdapter extends BaseAdapter {
 
   private ArrayList<WayPoint> objects;
   private Context context;
    
   public DataAdapter(Context context,ArrayList<WayPoint> objects) {
      super();
      // TODO Auto-generated constructor stub
      this.objects = new ArrayList<WayPoint>(objects);
      this.context = context;
   }
 
   @Override
   public int getCount() {
        
      return objects.size();
   }
   @Override
   public View getView(int position, View convertView, ViewGroup parent) {
      ViewHolder holder;
      View v = convertView;
      if(v ==null)
      {
         LayoutInflater vi = (LayoutInflater)context. getSystemService(Context.LAYOUT_INFLATER_SERVICE);
         v = vi.inflate(R.layout.list_row, null); 
         holder = new ViewHolder();
         holder.rowImage = (ImageView)v.findViewById(R.id.rowImage);          holder.rowAuthor = (TextView)v.findViewById(R.id.rowAuthor);
         holder.rowTitle = (TextView)v.findViewById(R.id.rowTitle);
         v.setTag(holder);      }
      else
      {
         holder = (ViewHolder)v.getTag();       }
      if(objects.get(position).getBitmap()==null) ………(1)
      {
         holder.rowImage.setBackgroundResource(R.drawable. defaultimage); 
      }
      else
      {
         holder.rowImage.setImageBitmap(objects.get (position).getBitmap());
      }
      holder.rowImage.setScaleType(ScaleType.FIT_XY);
      holder.rowAuthor.setText(objects.get(position). getAuthor());
      holder.rowTitle.setText(objects.get(position). getTitle());
      return v;
   }
 
   public ArrayList<WayPoint> getObjects() {
      return objects;
   }
 
   public Context getContext() {
      return context;
   }
 
   public void setObjects(ArrayList<WayPoint> objects) {
      this.objects = objects;
   }
 
   public void setContext(Context context) {
      this.context = context;
   }    
   staticclassViewHolder ……………………………(2)
   {    
      ImageView rowImage;
      TextView rowAuthor;
      TextView rowTitle;
   }
}


실제 매칭작업은 Adapter의 getView()에서 이뤄지므로 getView()를 중점적으로 보자. ListView를 조금 더 빠르게 하기 위해 ViewHolder 클래스를 사용했다. View가 null일 경우에는 LayoutInflater 클래스를 사용해 list_row.xml 파일을 View 형식으로 리턴받아서 각각의 위젯을 ViewHolder에 정의된 객체들과 연결시킨다. 만약 재사용된 View일 경우에는 getTag를 사용해 중복을 피한다. <리스트 5>의 (1)과 같이 아직 비트맵 저장이 되지 않았을 경우에는 DefaultImage를 보여주고 서버에서 이미지를 받아 온 경우에는 비트맵 이미지를 불러와 ImageView에 보여준다. 주의할 점은 list_row.xml에서 defaultImage로 설정했다고 (1)을 무시할 경우에는 View의 재사용성 때문에 앞의 List 이미지가 그대로 남아 있는 경우가 생기기 때문에 잊지 말고 null일 경우의 이미지도 설정해야 한다는 것이다. 장소와 이름은 TextTask에서 이미 받아 온 정보이므로 별다른 처리 없이 각각 맞는 TextView에 설정한다. 

ScrollListener을 이용한 ImageTask 실행
이제 모든 준비가 끝나고 DataListActivity에의 작업만이 남아있다. 여기서는 각각의 변수 선언 및 초기화, 레이아웃 파일 등을 설정할 것이며 가장 중요한 TextTask 및 ImageTask 실행코드를 구성할 것이다. ImageTask는 실행하는 데 여러 가지 방식이 존재하지만 필자는 주로 ListView의 ScrollListener을 등록해서 실행한다.

<리스트 6> DataListActivity.java

public class DataListActivity extends Activity implements OnScrollListener {
   /** Called when the activity is first created. */
   private ListView list;
   private DataAdapter adapter;
   private ProgressDialog dialog;
   private ArrayList<WayPoint> objects;
   private DB dbCon;
   private int taskPosition =-1;
    
   @Override
   public void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.main);        
      list = (ListView)findViewById(R.id.list);        
      dbCon = new DB(this);
      objects = new ArrayList<WayPoint>();
      adapter =new DataAdapter(this, objects);
      list.setAdapter(adapter);
      list.setOnScrollListener(this);
      TextTasktexttask=newTextTask(dbCon,adapter);
      texttask.execute(); …………………………(1) 
   }    
   @Override
   public void onScroll(AbsListView view, int firstVisibleItem,int visibleItemCount, int totalItemCount) {
        
      for(inti=firstVisibleItem;i<(firstVisibleItem+ visibleItemCount);i++) ………(2)
      {
         if(taskPosition<i)
         {
            startThread(i);
            taskPosition=i;
          }
      }
   }    
   public void startThread(int position)
   {
      ImageTask imagetask = new ImageTask(adapter);
      imagetask.execute(position); ……………………(3)   }
}


먼저 setContentsView를 통해 레이아웃 파일을 설정하고 ListView, CustomAdapter, 데이터를 갖고 있는 ArrayList 등을 선언 및 초기화한다. ListView에 Adapter를 설정하는데 액티비티가 시작될 때는 하나의 데이터도 갖고 있지 않기 때문에 ListView가 나타나지 않고 ScrollListener를 등록한 후 먼저 TextTask를 실행한다. TextTask의 모든 작업이 수행되고 나면 데이터가 업데이트돼 서버에서 받아 온 데이터의 수만큼 ListView가 화면에 나타나고 ListView의 onScroll이 호출된다. 필자는 onScroll이 호출될 때 ImageTask를 실행시키도록 구성했다. 맨 위에 있는 List의 position부터 화면에 보이고 모든 List의 이미지를 가져오도록 실행하며 taskPostion이라는 변수를 통해 실행됐던 position은 다시 실행되지 않도록 제한한다. 이렇게 설정하면 한 번에 ListView에 보이는 모든 이미지를 불러오는 것이 아니라 사용자가 현재 보고있는 ListView의 postion만 실행된다. 한 번에 많은 ImageTask를 실행시키는 것보다 훨씬 더 효율적이고 빠르게 이미지를 동적으로 받아올 수 있다. ListView에서 동적으로 서버의 데이터를 받아오는 방식은 아주 많으니 개발자가 느끼기에 가장 좋은 방법을 선택하면 된다. 필자가 사용한 방법도 완벽하지는 않지만 그동안 사용한 여러 가지 방식 중 개발자가 느끼기에 가장 뛰어난 방식이라고 생각해 주로 사용한다. 이 모든 것을 사용하기 위해서는 꼭 AndroidMa nifest.xml 파일에 INTERNET 퍼미션이 추가돼 있어야 한다(<이달의 디스켓>의 <리스트 3> 참조). 

이로써  AsyncTask를 사용해 서버의 데이터를 동적으로 받아오는 방식에 대해 알아봤다.

다른 카테고리의 글 목록

Dev. 안드로이드/참고소스 카테고리의 포스트를 톺아봅니다