6. Hooking it up with Loaders


Loaders

로더는 정말 대단하다!
로더는 Activity나 Fragment 내에서 비동기 데이터를 로딩할 때 최선의 작업방식이다
만들어진 로더는 Background Thread에 Data를 로딩할 AsyncTask를 만듭니다

첫 로딩이 완료되면 UI 스레드와 동기화합니다
이제 데이터를 모니터링하고 갱신되는 사항은 모두 UI 스레드로 보내도록 설정할 수 있습니다
더 좋은 점은 힘들게 DB에 ContentProvider를 추가한 혜택을 느낄 수 있습니다!

Cursorloader는 AsyncTask Loader의 하위 구현으로서 ContentProvider에 질의를 수행하고 커서를 반환합니다
이 Cursor는 직접 UI에 바인딩 할 수 있습니다

Cursorloader는 데이터베이스에 따라 ContentProvider가 변경되는 경우 Cursor를 자동으로 업데이트합니다
설정이 바뀌어서 Host Activity가 함께 다시 만들어지면 직전의 커서에 다시 연결하기 때문에 장치가 회전해도 데이터를 다시 쿼리 할 필요는 없습니다

Cursorloader는 Cursor의 관리, Background Thread의 생성, UI와 동기화, 데이터 소스의 모니터링을 처리합니다
ContentProvider를 이용하지 않는 것은 현명하지 않지만 그 경우에도 로더를 사용할 수 있습니다
AsyncTaskLoader를 상속받아서 사용자의 Loader를 만들 수 있습니다

AsyncTask Loader API

Loader는 데이터의 동기화를 위한 프레임워크를 제공합니다
Loader는 LoaderManager를 통해서 ID로 등록합니다
Loader를 등록함으로써 Lifecycle에 자유롭습니다

현재 우리 구조를 그대로 사용한다면 화면을 회전하거나 Activity가 restart 하는 경우 AsyncTask 모듈이 다시 호출 됩니다

AsyncTaskLoader는 AsyncTask와 같은 기능을 수행합니다만 Loader 이기 때문에 Lifecycle이 다릅니다

Loader의 Thread는 loadInBackgroud 에서 계속 실행 되게 됩니다

Activity가 불려져서 restart 하는 경우 LoaderManager는 이전의 Loader 가져오게 됩니다
작업이 완료된 후 AsyncTask의 onPostExecute()에 해당하는 메서드 onLoadFinished() 가 불려지게 됩니다

이렇게 진행 되기 때문에 기존의 구조처럼 2번 부르는 일은 발생하지 않습니다

Cursorloader는 AsyncTaskLoader의 하위 Class 입니다
좀 더 최적화가 되어있는 형태입니다

onLoadFinished를 통해서 Cursor가 전달되고 UI에 즉시 전달되는 형태입니다


데이터 베이스를 쿼리 할 때 여기에 있는 클래스를 이용하는 방법을 설명하겠습니다

  1. UI는 WeatherContract를 이용해서 URI를 구축합니다
  2. 이 URI를 가지고 ContentResolver를 호출합니다
  3. ContentResolver는 WeatherProvider에 요청을 전송합니다
  4. WeatherProvider는 DB Helper를 이용해서 WeatherDBHelper 인스턴스를 얻습니다
  5. WeatherDBHelper 인스턴스에 SQL Query를 전달하여서 SQLite 데이터베이스에 쿼리를 보냅니다

Cursorloader는 URI를 취득한 상태에서 우리가 직접 ContentResolver를 호출하는 방법 대신에 AsyncTask를 만들어서 ContentResolver를 호출합니다

Query 결과인 Cursor는 Android UI에 전달이 됩니다
CursorAdapter는 Cursor를 이용해서 View를 만들고 ListView 항목을 채우게 됩니다


이제 우리는 ArrayAdapter을 CursorAdapter로 변경합니다

ForecastAdapter.java
Utility.java

TestFetchWeatherTask.java

1
2
3
FetchWeatherTask fwt = new FetchWeatherTask(getContext(), null);
// 위에 내용을 아래처럼 바꿔주세요
FetchWeatherTask fwt = new FetchWeatherTask(getContext());

FetchWeatherTask.java

1
2
3
public class FetchWeatherTask extends AsyncTask<String, Void, String[]>{}
// 위에 내용을 아래처럼 바꿔주세요
public class FetchWeatherTask extends AsyncTask<String, Void, Void>{}
1
2
3
public FetchWeatherTask(Context context, ArrayAdapter<String> forecastAdapter)
// 위에 내용을 아래처럼 바꿔주세요
public FetchWeatherTask(Context context)

getReadableDateString(), formatHighLows(), convertContentValuesToUXFormat(), onPostExecute() 삭제

getWeatherDataFromJson() 반환형 void 변경
doInBackground() 반환형 Void 변경

getWeatherDataFromJson()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
private void getWeatherDataFromJson(String forecastJsonStr, String locationSetting)
throws JSONException {
final String OWM_CITY = "city";
final String OWM_CITY_NAME = "name";
final String OWM_COORD = "coord";

final String OWM_LATITUDE = "lat";
final String OWM_LONGITUDE = "lon";

final String OWM_LIST = "list";

final String OWM_PRESSURE = "pressure";
final String OWM_HUMIDITY = "humidity";
final String OWM_WINDSPEED = "speed";
final String OWM_WIND_DIRECTION = "deg";


final String OWM_TEMPERATURE = "temp";
final String OWM_MAX = "max";
final String OWM_MIN = "min";

final String OWM_WEATHER = "weather";
final String OWM_DESCRIPTION = "main";
final String OWM_WEATHER_ID = "id";

try {
JSONObject forecastJson = new JSONObject(forecastJsonStr);
JSONArray weatherArray = forecastJson.getJSONArray(OWM_LIST);

JSONObject cityJson = forecastJson.getJSONObject(OWM_CITY);
String cityName = cityJson.getString(OWM_CITY_NAME);

JSONObject cityCoord = cityJson.getJSONObject(OWM_COORD);
double cityLatitude = cityCoord.getDouble(OWM_LATITUDE);
double cityLongitude = cityCoord.getDouble(OWM_LONGITUDE);

long locationId = addLocation(locationSetting, cityName, cityLatitude, cityLongitude);

Vector<ContentValues> cVVector = new Vector<ContentValues>(weatherArray.length());

Time dayTime = new Time();
dayTime.setToNow();

int julianStartDay = Time.getJulianDay(System.currentTimeMillis(), dayTime.gmtoff);

dayTime = new Time();

for(int i = 0; i < weatherArray.length(); i++) {
long dateTime;
double pressure;
int humidity;
double windSpeed;
double windDirection;

double high;
double low;

String description;
int weatherId;

JSONObject dayForecast = weatherArray.getJSONObject(i);

dateTime = dayTime.setJulianDay(julianStartDay+i);

pressure = dayForecast.getDouble(OWM_PRESSURE);
humidity = dayForecast.getInt(OWM_HUMIDITY);
windSpeed = dayForecast.getDouble(OWM_WINDSPEED);
windDirection = dayForecast.getDouble(OWM_WIND_DIRECTION);

JSONObject weatherObject =
dayForecast.getJSONArray(OWM_WEATHER).getJSONObject(0);
description = weatherObject.getString(OWM_DESCRIPTION);
weatherId = weatherObject.getInt(OWM_WEATHER_ID);

JSONObject temperatureObject = dayForecast.getJSONObject(OWM_TEMPERATURE);
high = temperatureObject.getDouble(OWM_MAX);
low = temperatureObject.getDouble(OWM_MIN);

ContentValues weatherValues = new ContentValues();

weatherValues.put(WeatherEntry.COLUMN_LOC_KEY, locationId);
weatherValues.put(WeatherEntry.COLUMN_DATE, dateTime);
weatherValues.put(WeatherEntry.COLUMN_HUMIDITY, humidity);
weatherValues.put(WeatherEntry.COLUMN_PRESSURE, pressure);
weatherValues.put(WeatherEntry.COLUMN_WIND_SPEED, windSpeed);
weatherValues.put(WeatherEntry.COLUMN_DEGREES, windDirection);
weatherValues.put(WeatherEntry.COLUMN_MAX_TEMP, high);
weatherValues.put(WeatherEntry.COLUMN_MIN_TEMP, low);
weatherValues.put(WeatherEntry.COLUMN_SHORT_DESC, description);
weatherValues.put(WeatherEntry.COLUMN_WEATHER_ID, weatherId);

cVVector.add(weatherValues);
}

int inserted = 0;

if ( cVVector.size() > 0 ) {
ContentValues[] cvArray = new ContentValues[cVVector.size()];
cVVector.toArray(cvArray);
inserted = mContext.getContentResolver().bulkInsert(WeatherEntry.CONTENT_URI, cvArray);
}

Log.d(LOG_TAG, "FetchWeatherTask Complete. " + inserted + " Inserted");

} catch (JSONException e) {
Log.e(LOG_TAG, e.getMessage(), e);
e.printStackTrace();
}
}

ForecastFragment.java

1
2
3
ArrayAdapter<String> mForecastAdapter;
// 변경
ForecastAdapter mForecastAdapter;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
String locationSetting = Utility.getPreferredLocation(getActivity());
String sortOrder = WeatherContract.WeatherEntry.COLUMN_DATE + " ASC";
Uri weatherForLocationUri = WeatherContract.WeatherEntry.buildWeatherLocationWithStartDate(
locationSetting, System.currentTimeMillis());

Cursor cur = getActivity().getContentResolver().query(weatherForLocationUri,
null, null, null, sortOrder);

mForecastAdapter = new ForecastAdapter(getActivity(), cur, 0);

View rootView = inflater.inflate(R.layout.fragment_main, container, false);
ListView listView = (ListView) rootView.findViewById(R.id.listview_forecast);
listView.setAdapter(mForecastAdapter);

return rootView;
}

private void updateWeather() {
FetchWeatherTask fetchWeatherTask = new FetchWeatherTask(getActivity());
String location = Utility.getPreferredLocation(getActivity());
fetchWeatherTask.execute(location);
}

MainActivity.java

1
2
3
4
void openPreferredLocationInMap() {
String location = Utility.getPreferredLocation(this);
// 생략
}

CursorLoader

CursorLoader 만드는 방법은 총 3단계 입니다
쉽게 따라오세여

  1. LoaderID를 만드세요
    • 이건 단순한 상수 값입니다
    • private static final int MY_LOADER_ID = [MY_ID];
  2. Loader Callback을 사용합니다
    • Loader< Cursor > onCreateLoader(int i, Bundle bundel)
    • void onLoadFinished(Loadr< Cursor > cursorLoader, Cursor cursor)
      • 작업이 완료되어서 데이터를 사용할 수 있을 때 호출
      • 데이터 변경을 하기 위해서는 cursorAdapter.swapCursor(cursor)
    • void onLoaderReset(Loader< Cursor > cursorLoader)
      • 로더 파기 시에만 호출
      • 더이상 변경을 안하는 것은 cursorAdapter.swapCursor(null)
  3. LoaderManager를 이용해서 Loader를 초기화 합니다
  • 로더 ID를 통해서 초기화 합니다
    • getLoaderManager().initLoader([LoaderID], [Bundle], [loaderCallBack]);
  • Fragment에서 Loader를 사용하는 경우 onActivityCreated에서 초기화합니다

연습

  • ForecastFragment에 CursorLoader를 구현해 봅시다
  • 3가지를 이용해서 구현하면 됩니다

Loader Document
CursorLoader API

정답

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
implements LoaderManager.LoaderCallbacks<Cursor>

private static final int FORECAST_LOADER = 0;

mForecastAdapter = new ForecastAdapter(getActivity(), null, 0);

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
getLoaderManager().initLoader(FORECAST_LOADER, null, this);
super.onActivityCreated(savedInstanceState);
}

@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
String locationSetting = Utility.getPreferredLocation(getActivity());

String sortOrder = WeatherContract.WeatherEntry.COLUMN_DATE + " ASC";
Uri weatherForLocationUri = WeatherContract.WeatherEntry.buildWeatherLocationWithStartDate(
locationSetting, System.currentTimeMillis());

return new CursorLoader(getActivity(),
weatherForLocationUri,
null,
null,
null,
sortOrder);
}

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
mForecastAdapter.swapCursor(data);
}

@Override
public void onLoaderReset(Loader<Cursor> loader) {
mForecastAdapter.swapCursor(null);
}

Projections

실행하면 똑같아 보이지만 사실 Loader 이용해서 동작하고 있습니다

데이터 베이스에서 각 열은 인덱스(정수) 값으로 지정이 되어있습니다
그렇기 때문에 우리는 getColumnIndex() 메서드를 통해서 열의 인덱스를 알고 값을 가져왔습니다
(ForecastAdapter.convertCursorRowToUXFormat() 참고)

이것은 비 효율적입니다
인덱스 값은 순서대로 지정이 되어있습니다 우리가 요청한 열 순서대로 되어있습니다

Projections 을 이용해서 데이터를 가져오겠습니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static final int COL_WEATHER_ID = 0;
static final int COL_WEATHER_DATE = 1;
static final int COL_WEATHER_DESC = 2;
static final int COL_WEATHER_MAX_TEMP = 3;
static final int COL_WEATHER_MIN_TEMP = 4;
static final int COL_LOCATION_SETTING = 5;
static final int COL_WEATHER_CONDITION_ID = 6;
static final int COL_COORD_LAT = 7;
static final int COL_COORD_LONG = 8;

private static final String[] FORECAST_COLUMNS = {
WeatherContract.WeatherEntry.TABLE_NAME + "." + WeatherContract.WeatherEntry._ID,
WeatherContract.WeatherEntry.COLUMN_DATE,
WeatherContract.WeatherEntry.COLUMN_SHORT_DESC,
WeatherContract.WeatherEntry.COLUMN_MAX_TEMP,
WeatherContract.WeatherEntry.COLUMN_MIN_TEMP,
WeatherContract.LocationEntry.COLUMN_LOCATION_SETTING,
WeatherContract.WeatherEntry.COLUMN_WEATHER_ID,
WeatherContract.LocationEntry.COLUMN_COORD_LAT,
WeatherContract.LocationEntry.COLUMN_COORD_LONG
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
String locationSetting = Utility.getPreferredLocation(getActivity());

String sortOrder = WeatherContract.WeatherEntry.COLUMN_DATE + " ASC";
Uri weatherForLocationUri = WeatherContract.WeatherEntry.buildWeatherLocationWithStartDate(
locationSetting, System.currentTimeMillis());

return new CursorLoader(getActivity(),
weatherForLocationUri,
FORECAST_COLUMNS,
null,
null,
sortOrder);
}
1
2
3
4
5
6
7
8
9
10
private String convertCursorRowToUXFormat(Cursor cursor) {
String highAndLow = formatHighLows(
cursor.getDouble(ForecastFragment.COL_WEATHER_MAX_TEMP),
cursor.getDouble(ForecastFragment.COL_WEATHER_MIN_TEMP));

// Date - Weather - High/Low
return Utility.formatDate(cursor.getLong(ForecastFragment.COL_WEATHER_DATE)) +
" - " + cursor.getString(ForecastFragment.COL_WEATHER_DESC) +
" - " + highAndLow;
}

날씨 정보를 정상적으로 가져오는것을 확인 할 수 있습니다
다시 DetailActivity 하고 연결을 합시다

일단 DetailActivity에서 작업을 시작합니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_detail, container, false);
Intent intent = getActivity().getIntent();

if (intent != null) {
mForecastStr = intent.getDataString();
}

if (null != mForecastStr) {
((TextView) rootView.findViewById(R.id.detail_text)).setText(mForecastStr);
}
return rootView;
}

ForecastFragment에서 ListView ItemClick액션을 추가해줍니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int position, long l) {
Cursor cursor = (Cursor) adapterView.getItemAtPosition(position);
if (cursor != null) {
String locationSetting = Utility.getPreferredLocation(getActivity());
Intent intent = new Intent(getActivity(), DetailActivity.class)
.setData(WeatherContract.WeatherEntry.buildWeatherLocationWithDate(
locationSetting, cursor.getLong(COL_WEATHER_DATE)
));
startActivity(intent);
}
}
});

실행을 해보면 URI가 잘 나오는것을 확인할 수 있습니다

연습

  • DetailActivity에서 받아온 URI를 가지고 날씨 정보를 표시하세요

정답


동작에는 문제가 없습니다 하지만 간단한 문제가 있습니다
설정값을 변경하여도 실시간 반영이 안됩니다

간단한 테크닉을 통해서 수정합시다

ForecastFragment.java

1
2
3
4
void onLocationChanged() {
updateWeather();
getLoaderManager().restartLoader(FORECAST_LOADER, null, this);
}

추가하고 onStart는 삭제합니다

MainActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private String mLocation; //추가

@Override
protected void onResume() {
super.onResume();
String location = Utility.getPreferredLocation(this);

if (location != null && !location.equals(mLocation)) {
Fragment fragment = getVisibleFragment();

if(fragment instanceof ForecastFragment) {
Log.i(LOG_TAG, "Location Change");
((ForecastFragment)fragment).onLocationChanged();
}

mLocation = location;
}
}

public Fragment getVisibleFragment() {
for (Fragment fragment : getSupportFragmentManager().getFragments()) {
if (fragment.isVisible()) {
return fragment;
}
}
return null;
}

이제 정상적으로 작동 합니다


ContentProvider를 다른앱에서 사용 가능하게 하는 것은 간단합니다

AndroidManifests.xml 파일에서 provider 속성 중에 exported 를 true 변경하면 됩니다
이것만으로도 Content URI를 안다면 어떤 응용 프로그램에서도 접근할 수 있습니다

데이터의 민감도에 따라서 권한을 설정을 해서 접근하게 만들 수도 있습니다

1
2
3
4
5
6
7
8
9
10
11
<provider
android:name=".MyContentProvider"
android:authorities="com.myapp.myauthorities"
android:enabled="true"
android:exported="true"
android:permission="com.myapp.LICENSE_TO_KILL"/>

<permission
android:name="com.myapp.LICENSE_TO_KILL"
android:label="Licenced to Kill"
android:protectionLevel="dangerous"/>

해당 permission을 아는 앱에서만 사용 할 수 있습니다
해당하는 URI와 열 정보만 제공을 하면 다른 앱에서도 사용할 수가 있습니다
Android 내부 기본 앱들은 그렇게 데이터를 제공하고 있습니다

연습

  • 네이티브 ContentProvider를 알아보세요
  • 오디오 파일에 접근 할 수 있는 URI를 알아보세여

android.provider
Calendar Provider
Contacts Provider

정답 : MediaStore.Audio.Media.INTERNAL_CONTENT_URI


복습은 필수

  • Loader
  • CursorAdapter
  • Projections
  • ContentProvider Permission