5. Content Providers


Content Providers

Why?

왜 Content Providers를 사용하는가? 묻는 것은 이상한것이 아닙니다
데이터를 안전하게 공유하고 효율적으로 app의 경계선을 넘어서 파일들이나 또는 정말 어떤 다른 것과 같이 넘으려고 하는 것을 허용하기 때문이다
캘린더, SMS와 같은 것은 Shared content providers를 이용하여 작동된다
Content Providers를 설계하는것은 나중에 다른 App에 날씨 데이터를 공유 하기 위해서 입니다

만약 공유할 필요가 없으면 넘어 가면 됩니다!
하지만, 우리는 공부가 목적이닌깐 만듭시다!!

content providers

ContentProvider를 이용해서 widgets, Search가 작동하는 방법입니다
APIs의 상당량은 syncing, querying data, UI의 updating을 적절히 하는 과정의 최적화 설계가 되어있습니다
Syncadapter, Cursorloader를 나중에 이용하게 됩니다
Content Observer를 이용해서 UI를 자동적으로 업데이트 하게 만들 수 있습니다


Content Uri

우리는 이제 실제로 작업을 시작해야합니다
다음과 같은 순서로 진행을 합니다

  1. Application 에서 지원하는 URI를 결정합니다
  2. Contract의 내용을 추가합니다
  3. URI를 각각 지원하는 UriMatcher를 사용합니다
  4. ContentProvider의 6개의 함수를 구현합니다

1. Application 에서 지원하는 URI를 결정합니다

1
content://com.study.sunshine.app/weather/Seoul,kr

다음과 같이 Seoul지역의 날씨를 조회하는 URI를 사용합니다

커서를 통해서 다음 URI의 반환값을 줍니다

1
2
3
content://com.study.sunshine.app/weather/Busan,kr

content://com.study.sunshine.app/weather/Tokyo,jp

다음과 같이 URI를 변경하면 다른 국가, 다른 지역의 날씨 정보를 가져올 수 있습니다


  • content:

SCHEME : URI에 사용되는 프로토콜을 식별합니다

  • com.study.sunshine.app

AUTHORITY : 콘텐츠를 찾는 데 사용되는 고유 글자, 거의 항상 Application의 패키지 이름으로 합니다

  • weather

데이터베이스의 테이블을 가리키는 위치입니다

  • Seoul,kr

QUERY : 테이블의 원하는 쿼리 데이터를 넣는 것 입니다

  • Seoul,kr?DATE=1485700000

다음과 같은 형태로 일반적인 쿼리 형태를 취할 수도 있습니다
DATE -> 1970. 01. 01. 00:00:00 을 기준으로 합니다


다음과 같은 URI를 정했습니다

2번은 우리의 첫페이지에서 날씨 예보를 보는데 사용하겠고
3번은 상세 날씨보기에서 사용을 합니다

1,4번은 데이터 삽입, 수정, 삭제에 사용합니다
기본 URI를 잡아두면 테스팅할때 매우 유용합니다

1
2
3
4
5
6
7
8
static final int WEATHER = 100;
//content://com.study.sunshine.app/weather
static final int WEATHER_WITH_LOCATION = 101;
//content://com.study.sunshine.app/weather/[LOCATION]
static final int WEATHER_WITH_LOCATION_AND_DATE = 102;
//content://com.study.sunshine.app/weather/[LOCATION]/[DATE]
static final int LOCATION = 300;
//content://com.study.sunshine.app/location

androidTest - data 폴더
TestProvider.java
TestUriMatcher.java
TestWeatherContract.java

androidTest 폴더
TestFetchWeatherTask.java

기본package data 폴더
WeatherProvider.java
WeatherContract.java


WeatherContract 에 URI 관련 정보를 추가했습니다

1
2
3
public static final String CONTENT_AUTHORITY = "본인패키지명.앱이름.app";

public static final Uri BASE_CONTENT_URI = Uri.parse("content://" + CONTENT_AUTHORITY);

CONTENT_AUTHORITY를 이용해서 CONTENT_AUTHORITY_URI를 만들어 줍니다

1
2
public static final String PATH_WEATHER = "weather";
public static final String PATH_LOCATION = "location";

각각 테이블을 의미합니다

1
2
3
4
5
6
7
public static Uri buildWeatherUri(long id)

public static Uri buildWeatherLocation(String locationSetting)

public static Uri buildWeatherLocationWithStartDate(String locationSetting, long startDate)

public static Uri buildWeatherLocationWithDate(String locationSetting, long date)

EnCoding

1
2
3
4
5
public static String getLocationSettingFromUri(Uri uri)

public static long getDateFromUri(Uri uri)

public static long getStartDateFromUri(Uri uri)

DeCoding

UriBuilder API


연습

  • WeatherContract.WeatherEntry.buildWeatherLocation() 완성합니다
  • TestWeatherContract.testBuildWeatherLocation() 이용해서 테스트합니다

정답

1
2
3
public static Uri buildWeatherLocation(String locationSetting) {
return CONTENT_URI.buildUpon().appendPath(locationSetting).build();
}

UriMatcher

ContentProvider에는 UriMatcher를 쉽게 사용할 방법을 제공하고 있습니다
ContentProvider는 전달된 URI 따라서 각각의 기능을 수행합니다
우리는 4가지의 URI를 구현합니다

각각 다른 작업을 수행합니다

1
2
3
4
5
6
7
8
static final int WEATHER = 100;
//content://com.study.sunshine.app/weather
static final int WEATHER_WITH_LOCATION = 101;
//content://com.study.sunshine.app/weather/[LOCATION]
static final int WEATHER_WITH_LOCATION_AND_DATE = 102;
//content://com.study.sunshine.app/weather/[LOCATION]/[DATE]
static final int LOCATION = 300;
//content://com.study.sunshine.app/location

정해진 static final int 값과 비교하여서 작업을 수행합니다
ContentProvider에 전달된 URI의 형태를 알수있는 방법이 필요하기 때문입니다
switch - case 문을 이용해서 손 쉽게 처리할 수 있습니다
UriMatcher는 다양한 형태의 URI에 표현 구문을 일치 시킬 정규표현식과 같은 역할을 합니다

# : 숫자
* : 모든 문자열

UriMatcher API

연습

  • WeatherProvider.buildUriMatcher() 완성합니다
  • TestUriMatcher.testUriMatcher() 이용해서 테스트합니다

정답

1
2
3
4
5
6
7
8
9
10
11
static UriMatcher buildUriMatcher() {
final UriMatcher matcher = new UriMatcher(UriMatcher.NO_MATCH);
final String authority = WeatherContract.CONTENT_AUTHORITY;

matcher.addURI(authority, WeatherContract.PATH_WEATHER, WEATHER);
matcher.addURI(authority, WeatherContract.PATH_WEATHER + "/*", WEATHER_WITH_LOCATION);
matcher.addURI(authority, WeatherContract.PATH_WEATHER + "/*/#", WEATHER_WITH_LOCATION_AND_DATE);

matcher.addURI(authority, WeatherContract.PATH_LOCATION, LOCATION);
return matcher;
}

ContentProvider Registry

UriMatcher는 ContentProvider를 구현할 때 핵심입니다!!
ContentProvider를 사용하기 전에 AndroidManifest.xml에 등록을 하여야 합니다

1
2
3
<provider
android:name=[CONTENT PROVIDER CLASS]
android:authorities=[CONTENT_AUTHORITY]/>

ContentResolver라는 Android Util Class를 사용할 수 있습니다
ContentResolver는 AUTHORITY를 사용하여 클래스를 찾아 WeatherProvider를 호출합니다
AndroidManifest에 ContentProvider를 등록하여서 WeatherContract와 WeatherContract.CONTENT_AUTHORITY를 등록합시다

Manifest - Providers
ContentProvider

연습

  • AndroidManifest에 provider를 등록하세요
  • TestProvider.testProviderRegistry() 이용해서 테스트합니다

정답

1
2
3
<provider
android:name=".data.WeatherProvider"
android:authorities="본인패키지명.앱이름.app"/>

ContentProvider 6 function

AndroidManifest에 provider를 등록했습니다
이제 WeatherProvider를 코딩합시다

ContentProvider 구현이 가장 어려운 부분입니다
6개의 함수를 구현해야(Override) 합니다

1
2
3
4
5
6
7
8
boolean onCreate()
Cursor query (Uri uri, String[] projection,String selection,
String[] selectionArgs, String sortOrder)
Uri insert (Uri uri, ContentValues values)
int update (Uri uri, ContentValues values,
String selection, String[] selectionArgs)
int delete (Uri uri,String selection, String[] selectionArgs)
String getType (Uri uri)

1
2
3
4
public boolean onCreate() {
mOpenHelper = new WeatherDbHelper(getContext());
return true;
}

OpenHelper를 인스턴스 등록하여서 사용합니다

1
2
3
4
5
6
7
8
9
10
11
12
public String getType(Uri uri) {
final int match = sUriMatcher.match(uri);

switch (match) {
case WEATHER:
return WeatherContract.WeatherEntry.CONTENT_TYPE;
case LOCATION:
return WeatherContract.LocationEntry.CONTENT_TYPE;
default:
throw new UnsupportedOperationException("Unknown uri: " + uri);
}
}

입력된 URI가 어떤 형태를 요구하는지 UriMatcher를 통해서 알려줍니다

연습

  • WeatherProvider.getType(Uri uri) 완성합니다
  • TestProvider.testGetType() 이용해서 테스트합니다

정답

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public String getType(Uri uri) {
final int match = sUriMatcher.match(uri);

switch (match) {
case WEATHER_WITH_LOCATION_AND_DATE:
return WeatherContract.WeatherEntry.CONTENT_ITEM_TYPE;
case WEATHER_WITH_LOCATION:
return WeatherContract.WeatherEntry.CONTENT_TYPE;
case WEATHER:
return WeatherContract.WeatherEntry.CONTENT_TYPE;
case LOCATION:
return WeatherContract.LocationEntry.CONTENT_TYPE;
default:
throw new UnsupportedOperationException("Unknown uri: " + uri);
}
}

WEATHER_WITH_LOCATION_AND_DATE 은 하나의 아이템이 나와야 합니다
WEATHER_WITH_LOCATION은 디렉토리가 나와야 합니다


1
2
public Cursor query(Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder)

query 함수는 제일 구현하기 복잡하고 어렵습니다
기본 코드에 뼈대만 잡혀 있습니다
이것을 이용해서 구현합니다

switch - case 문을 이용해서 각각의 Cursor를 반환합니다
함수 끝 부분에서 전달된 URI를 설정합니다
URI의 변화를 모니터링 하기 위해서 입니다

연습

  • WeatherProvider.query() 완성합니다
  • TestProvider.testBasicWeatherQuery(), TestProvider.testBasicLocationQueries() 이용해서 테스트합니다

정답

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
case WEATHER: {
retCursor = mOpenHelper.getReadableDatabase().query(
WeatherContract.WeatherEntry.TABLE_NAME,
projection,
selection,
selectionArgs,
null,
null,
sortOrder
);
break;
}
case LOCATION: {
retCursor = mOpenHelper.getReadableDatabase().query(
WeatherContract.LocationEntry.TABLE_NAME,
projection,
selection,
selectionArgs,
null,
null,
sortOrder
);
break;
}

다음과 같이 우리는 테이블관의 관계를 설정했습니다
Join을 사용하여 쿼리를 구현하는 방법을 설명하겠습니다

1
private static final SQLiteQueryBuilder sWeatherByLocationSettingQueryBuilder;

SQLiteQueryBuilder는 Query를 완성하는데 도움을 줍니다

1
2
3
4
5
6
7
sWeatherByLocationSettingQueryBuilder.setTables(
WeatherContract.WeatherEntry.TABLE_NAME + " INNER JOIN " +
WeatherContract.LocationEntry.TABLE_NAME +
" ON " + WeatherContract.WeatherEntry.TABLE_NAME +
"." + WeatherContract.WeatherEntry.COLUMN_LOC_KEY +
" = " + WeatherContract.LocationEntry.TABLE_NAME +
"." + WeatherContract.LocationEntry._ID);

setTables는 SQL 쿼리의 내용의 일부를 설명하게 됩니다

1
2
3
private static final String sLocationSettingSelection =
WeatherContract.LocationEntry.TABLE_NAME +
"." + WeatherContract.LocationEntry.COLUMN_LOCATION_SETTING + " = ? ";

기본형 쿼리를 완성해 두고 ? 에는 우리가 전달하는 값이 들어갑니다

getWeatherByLocationSetting()
getWeatherByLocationSettingAndDate()

메서드에서 사용하는 모습을 볼 수 있습니다

이제 insert를 해봅시다

다른 함수와 마찬가지로 UriMatcher를 이용해서 합니다
insert는 단순합니다
테이블에 맞는 데이터를 맞게 insert 하면 됩니다
ContentValues를 이용해서 삽입합니다

그러나 어려운 점이 존재합니다!
데이터베이스의 데이터를 삽입하면 변경된 데이터를 가질 가능성이 있는 모든 곳에 알려줘야 합니다

루트가 되는 URI에 알림을 하면 하위의 모든 URI에 알림이 됩니다
ContentResolver를 사용해서 ContentObserver에게 알려 줄 수 있다

하위 URI에만 알림을 해주면 루트 URI에는 알림이 오지 않습니다
항상 이점을 명심하고 주의해야합니다

1
2
3
4
5
6
7
8
9
case WEATHER: {
normalizeDate(values);
long _id = db.insert(WeatherContract.WeatherEntry.TABLE_NAME, null, values);
if (_id > 0)
returnUri = WeatherContract.WeatherEntry.buildWeatherUri(_id);
else
throw new android.database.SQLException("Failed to insert row into " + uri);
break;
}

데이터 삽입이 정상적으로 이루어졌는지 검사를 하고 애러 처리까지 해줍니다
_id 까지 포함이 된 Uri를 만들어서 반환해 주면 끝입니다

ContentProvider를 완벽히 구현한다면 Contract, UriMatcher, query 함수 의 URI형을 다 구현해야 합니다

연습

  • WeatherProvider.insert() 완성합니다
  • TestProvider.testInsertReadProvider 이용해서 테스트합니다

정답

1
2
3
4
5
6
7
8
case LOCATION: {
long _id = db.insert(WeatherContract.LocationEntry.TABLE_NAME, null, values);
if (_id > 0)
returnUri = WeatherContract.LocationEntry.buildLocationUri(_id);
else
throw new android.database.SQLException("Failed to insert row into " + uri);
break;
}

남은 것은 update, delete입니다
insert와 비슷합니다

다만 다른 점이라고 하면 삭제된 곳이나 변경이 된곳의 _id를 알려줘야 합니다
URI가 아닌 int (_id) 라는 점 입니다

테스트를 하기 전에 다음과 같이 변경 합니다

TestDb.java

1
2
3
public void deleteAllRecords() {
deleteAllRecordsFromProvider();
}

더 이상 DB로 직접 지우는 게 아니라 ContentProvider를 이용해야 하기 때문에 변경합니다

연습

  • WeatherProvider.update, WeatherProvider.delete 완성합니다
  • TestProvider.testUpdateLocation, TestProvider.testDeleteRecords 이용해서 테스트합니다

정답

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
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
final int match = sUriMatcher.match(uri);
int rowsDeleted;

if (null == selection) selection = "1";
switch (match) {
case WEATHER:
rowsDeleted = db.delete(
WeatherContract.WeatherEntry.TABLE_NAME, selection, selectionArgs);
break;
case LOCATION:
rowsDeleted = db.delete(
WeatherContract.LocationEntry.TABLE_NAME, selection, selectionArgs);
break;
default:
throw new UnsupportedOperationException("Unknown uri: " + uri);
}

if (rowsDeleted != 0) {
getContext().getContentResolver().notifyChange(uri, null);
}
db.close();
return rowsDeleted;
}
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
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
final int match = sUriMatcher.match(uri);
int rowsUpdated;

switch (match) {
case WEATHER:
normalizeDate(values);
rowsUpdated = db.update(WeatherContract.WeatherEntry.TABLE_NAME, values, selection,
selectionArgs);
break;
case LOCATION:
rowsUpdated = db.update(WeatherContract.LocationEntry.TABLE_NAME, values, selection,
selectionArgs);
break;
default:
throw new UnsupportedOperationException("Unknown uri: " + uri);
}
if (rowsUpdated != 0) {
getContext().getContentResolver().notifyChange(uri, null);
}
db.close();
return rowsUpdated;
}

우리는 다 완성 했습니다
하지만 좀 더 효율적으로 할 필요가 있습니다
Database를 다루다 보면 하나의 데이터를 삽입하는 것보다 여러개 데이터를 한번에 처리하는것이 더 빠릅니다
트랜잭션으로 그룹화 해서 처리하는게 더 효율적입니다

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
@Override
public int bulkInsert(Uri uri, ContentValues[] values) {
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
final int match = sUriMatcher.match(uri);
switch (match) {
case WEATHER:
db.beginTransaction();
int returnCount = 0;
try {
for (ContentValues value : values) {
normalizeDate(value);
long _id = db.insert(WeatherContract.WeatherEntry.TABLE_NAME, null, value);
if (_id != -1) {
returnCount++;
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
getContext().getContentResolver().notifyChange(uri, null);
return returnCount;
default:
return super.bulkInsert(uri, values);
}
}

setTransactionSuccessful()를 호출하지 않으면 기록이 되지 않는다는 점을 명심하세여

ContentProvider를 완벽하게 다 만들었습니다

  1. Application 에서 지원하는 URI를 결정했습니다
  2. Contract의 UriMatcher와 관련된 내용을 추가하였습니다
  3. URI를 각각 지원하는 UriMatcher를 사용해서 구현했습니다
  4. ContentProvider의 6개의 함수를 구현했습니다

FetchWeatherTask Refactoring

ContentProvider를 완성했으니 이제 코드를 Refactoring을 합시다
기존에 FetchWeatherTask class를 파일로 빼고 수정을 합니다

package 폴더
FetchWeatherTask.java

1
2
3
4
private void updateWeather() {
FetchWeatherTask fetchWeatherTask = new FetchWeatherTask(getActivity(), mForecastAdapter);
// 생략
}

FetchWeatherTask의 생성자를 살펴보면 다음 코드를 이해할 수 있습니다

1
2
3
4
public FetchWeatherTask(Context context, ArrayAdapter<String> forecastAdapter) {
mContext = context;
mForecastAdapter = forecastAdapter;
}

FetchWeatherTask.addLocation() 메서드를 완성 해 봅시다
도시의 이름, 위도, 경도를 입력하게 됩니다

DB에 있는 도시이면 id 값을 찾아서 반환하고, 없는 도시이면 저장 후 반환합니다

연습

  • FetchWeatherTask.addLocation() 완성합니다
  • TestFetchWeatherTask.testAddLocation() 이용해서 테스트합니다

정답

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
long addLocation(String locationSetting, String cityName, double lat, double lon) {
long locationId;

Cursor locationCursor = mContext.getContentResolver().query(
WeatherContract.LocationEntry.CONTENT_URI,
new String[]{WeatherContract.LocationEntry._ID},
WeatherContract.LocationEntry.COLUMN_LOCATION_SETTING + " = ?",
new String[]{locationSetting},
null);

if (locationCursor.moveToFirst()) {
int locationIdIndex = locationCursor.getColumnIndex(WeatherContract.LocationEntry._ID);
locationId = locationCursor.getLong(locationIdIndex);
} else {
ContentValues locationValues = new ContentValues();

locationValues.put(WeatherContract.LocationEntry.COLUMN_CITY_NAME, cityName);
locationValues.put(WeatherContract.LocationEntry.COLUMN_LOCATION_SETTING, locationSetting);
locationValues.put(WeatherContract.LocationEntry.COLUMN_COORD_LAT, lat);
locationValues.put(WeatherContract.LocationEntry.COLUMN_COORD_LONG, lon);

Uri insertedUri = mContext.getContentResolver().insert(
WeatherContract.LocationEntry.CONTENT_URI,
locationValues
);

locationId = ContentUris.parseId(insertedUri);
}

locationCursor.close();
return locationId;
}

bulkInsert는 트랙잭션을 효율적으로 이용하여서 여러개의 데이터를 효율적으로 삽입할 수 있습니다

우리는 기존의 일주일 데이터가 아닌 2주일 데이터를 저장도록 합시다

연습

  • FetchWeatherTask.getWeatherDataFromJson() 완성합니다
  • TestProvider.testBulkInsert() 이용해서 테스트합니다

정답

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if (cVVector.size() > 0) {
ContentValues[] cvArray = new ContentValues[cVVector.size()];
cVVector.toArray(cvArray);
mContext.getContentResolver().bulkInsert(WeatherEntry.CONTENT_URI, cvArray);
}

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

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

cVVector = new Vector<ContentValues>(cur.getCount());
if (cur.moveToFirst()) {
do {
ContentValues cv = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cur, cv);
cVVector.add(cv);
} while (cur.moveToNext());
}

이제 실행하면 이주일 동안의 날씨 정보를 볼 수 있습니다


복습은 필수

  • ContentProvider
  • URI 설계
  • UriMatcher
  • ContentProvider Registry
  • ContentProvider 6 function
  • FetchWeatherTask Refactoring