검색 가능한 TV 앱 만들기

Android TV는 Android 검색 인터페이스를 사용하여 설치된 앱에서 콘텐츠 데이터를 가져와서 사용자에게 검색결과를 전달합니다. 이러한 결과에 앱의 콘텐츠 데이터를 포함하여 사용자가 앱의 콘텐츠에 즉시 액세스하도록 할 수 있습니다.

앱은 사용자가 검색 대화상자에 문자를 입력할 때 Android TV가 추천 검색결과를 생성할 수 있는 데이터 필드를 Android TV에 제공해야 합니다. 그렇게 하려면 앱에서 콘텐츠 제공자 및 Android TV에 관한 기타 중요한 정보를 설명하는 searchable.xml 구성 파일과 함께 추천을 제공하는 콘텐츠 제공자를 구현해야 합니다. 또한 사용자가 추천 검색결과를 선택할 때 실행되는 인텐트를 처리하는 활동이 필요합니다. 자세한 내용은 맞춤 추천 검색어 추가를 참고하세요. 이 가이드에서는 Android TV 앱과 관련된 주요 사항을 다룹니다.

이 가이드를 읽기 전에 Search API 가이드에 설명된 개념을 숙지해야 합니다. 검색 기능 추가도 검토하세요.

이 가이드의 샘플 코드는 Leanback 샘플 앱에서 가져온 것입니다.

열 식별

SearchManager는 예상되는 데이터 필드를 로컬 데이터베이스의 열로 표시하여 설명합니다. 데이터의 형식과 관계없이 일반적으로 콘텐츠 데이터에 액세스하는 클래스에서 데이터 필드를 이러한 열에 매핑해야 합니다. 기존 데이터를 필수 필드에 매핑하는 클래스를 만드는 방법에 관한 자세한 내용은 추천 테이블 만들기를 참고하세요.

SearchManager 클래스에는 Android TV를 위한 여러 열이 포함되어 있습니다. 다음 표에는 보다 중요한 열 중 일부가 설명되어 있습니다.

설명
SUGGEST_COLUMN_TEXT_1 콘텐츠의 이름 (필수)
SUGGEST_COLUMN_TEXT_2 콘텐츠의 텍스트 설명
SUGGEST_COLUMN_RESULT_CARD_IMAGE 콘텐츠의 이미지, 포스터 또는 표지
SUGGEST_COLUMN_CONTENT_TYPE 미디어의 MIME 유형
SUGGEST_COLUMN_VIDEO_WIDTH 미디어의 해상도 너비
SUGGEST_COLUMN_VIDEO_HEIGHT 미디어의 해상도 높이
SUGGEST_COLUMN_PRODUCTION_YEAR 콘텐츠 제작 연도 (필수)
SUGGEST_COLUMN_DURATION 미디어의 재생 시간(밀리초)(필수)

검색 프레임워크에는 다음 열이 필요합니다.

콘텐츠의 이 열 값이 Google 서버에서 찾은 다른 제공업체의 동일한 콘텐츠 값과 일치하는 경우 시스템은 콘텐츠의 세부정보 뷰에서 딥 링크를 다른 제공업체의 앱으로 연결되는 링크와 함께 앱에 제공합니다. 자세한 내용은 세부정보 화면에 표시되는 앱 딥 링크 섹션에서 자세히 설명합니다.

애플리케이션의 데이터베이스 클래스에 열이 다음과 같이 정의될 수 있습니다.

Kotlin

class VideoDatabase {
    companion object {
        // The columns we'll include in the video database table
        val KEY_NAME = SearchManager.SUGGEST_COLUMN_TEXT_1
        val KEY_DESCRIPTION = SearchManager.SUGGEST_COLUMN_TEXT_2
        val KEY_ICON = SearchManager.SUGGEST_COLUMN_RESULT_CARD_IMAGE
        val KEY_DATA_TYPE = SearchManager.SUGGEST_COLUMN_CONTENT_TYPE
        val KEY_IS_LIVE = SearchManager.SUGGEST_COLUMN_IS_LIVE
        val KEY_VIDEO_WIDTH = SearchManager.SUGGEST_COLUMN_VIDEO_WIDTH
        val KEY_VIDEO_HEIGHT = SearchManager.SUGGEST_COLUMN_VIDEO_HEIGHT
        val KEY_AUDIO_CHANNEL_CONFIG = SearchManager.SUGGEST_COLUMN_AUDIO_CHANNEL_CONFIG
        val KEY_PURCHASE_PRICE = SearchManager.SUGGEST_COLUMN_PURCHASE_PRICE
        val KEY_RENTAL_PRICE = SearchManager.SUGGEST_COLUMN_RENTAL_PRICE
        val KEY_RATING_STYLE = SearchManager.SUGGEST_COLUMN_RATING_STYLE
        val KEY_RATING_SCORE = SearchManager.SUGGEST_COLUMN_RATING_SCORE
        val KEY_PRODUCTION_YEAR = SearchManager.SUGGEST_COLUMN_PRODUCTION_YEAR
        val KEY_COLUMN_DURATION = SearchManager.SUGGEST_COLUMN_DURATION
        val KEY_ACTION = SearchManager.SUGGEST_COLUMN_INTENT_ACTION
        ...
    }
    ...
}

Java

public class VideoDatabase {
    // The columns we'll include in the video database table
    public static final String KEY_NAME = SearchManager.SUGGEST_COLUMN_TEXT_1;
    public static final String KEY_DESCRIPTION = SearchManager.SUGGEST_COLUMN_TEXT_2;
    public static final String KEY_ICON = SearchManager.SUGGEST_COLUMN_RESULT_CARD_IMAGE;
    public static final String KEY_DATA_TYPE = SearchManager.SUGGEST_COLUMN_CONTENT_TYPE;
    public static final String KEY_IS_LIVE = SearchManager.SUGGEST_COLUMN_IS_LIVE;
    public static final String KEY_VIDEO_WIDTH = SearchManager.SUGGEST_COLUMN_VIDEO_WIDTH;
    public static final String KEY_VIDEO_HEIGHT = SearchManager.SUGGEST_COLUMN_VIDEO_HEIGHT;
    public static final String KEY_AUDIO_CHANNEL_CONFIG =
            SearchManager.SUGGEST_COLUMN_AUDIO_CHANNEL_CONFIG;
    public static final String KEY_PURCHASE_PRICE = SearchManager.SUGGEST_COLUMN_PURCHASE_PRICE;
    public static final String KEY_RENTAL_PRICE = SearchManager.SUGGEST_COLUMN_RENTAL_PRICE;
    public static final String KEY_RATING_STYLE = SearchManager.SUGGEST_COLUMN_RATING_STYLE;
    public static final String KEY_RATING_SCORE = SearchManager.SUGGEST_COLUMN_RATING_SCORE;
    public static final String KEY_PRODUCTION_YEAR = SearchManager.SUGGEST_COLUMN_PRODUCTION_YEAR;
    public static final String KEY_COLUMN_DURATION = SearchManager.SUGGEST_COLUMN_DURATION;
    public static final String KEY_ACTION = SearchManager.SUGGEST_COLUMN_INTENT_ACTION;
...

SearchManager 열과 데이터 필드 간의 매핑을 빌드할 때는 _ID도 지정하여 각 행에 고유 ID를 부여해야 합니다.

Kotlin


companion object {
    ....
    private fun buildColumnMap(): Map<String, String> {
        return mapOf(
          KEY_NAME to KEY_NAME,
          KEY_DESCRIPTION to KEY_DESCRIPTION,
          KEY_ICON to KEY_ICON,
          KEY_DATA_TYPE to KEY_DATA_TYPE,
          KEY_IS_LIVE to KEY_IS_LIVE,
          KEY_VIDEO_WIDTH to KEY_VIDEO_WIDTH,
          KEY_VIDEO_HEIGHT to KEY_VIDEO_HEIGHT,
          KEY_AUDIO_CHANNEL_CONFIG to KEY_AUDIO_CHANNEL_CONFIG,
          KEY_PURCHASE_PRICE to KEY_PURCHASE_PRICE,
          KEY_RENTAL_PRICE to KEY_RENTAL_PRICE,
          KEY_RATING_STYLE to KEY_RATING_STYLE,
          KEY_RATING_SCORE to KEY_RATING_SCORE,
          KEY_PRODUCTION_YEAR to KEY_PRODUCTION_YEAR,
          KEY_COLUMN_DURATION to KEY_COLUMN_DURATION,
          KEY_ACTION to KEY_ACTION,
          BaseColumns._ID to ("rowid AS " + BaseColumns._ID),
          SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID to ("rowid AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID),
          SearchManager.SUGGEST_COLUMN_SHORTCUT_ID to ("rowid AS " + SearchManager.SUGGEST_COLUMN_SHORTCUT_ID)
        )
    }
}

Java

...
  private static HashMap<String, String> buildColumnMap() {
    HashMap<String, String> map = new HashMap<String, String>();
    map.put(KEY_NAME, KEY_NAME);
    map.put(KEY_DESCRIPTION, KEY_DESCRIPTION);
    map.put(KEY_ICON, KEY_ICON);
    map.put(KEY_DATA_TYPE, KEY_DATA_TYPE);
    map.put(KEY_IS_LIVE, KEY_IS_LIVE);
    map.put(KEY_VIDEO_WIDTH, KEY_VIDEO_WIDTH);
    map.put(KEY_VIDEO_HEIGHT, KEY_VIDEO_HEIGHT);
    map.put(KEY_AUDIO_CHANNEL_CONFIG, KEY_AUDIO_CHANNEL_CONFIG);
    map.put(KEY_PURCHASE_PRICE, KEY_PURCHASE_PRICE);
    map.put(KEY_RENTAL_PRICE, KEY_RENTAL_PRICE);
    map.put(KEY_RATING_STYLE, KEY_RATING_STYLE);
    map.put(KEY_RATING_SCORE, KEY_RATING_SCORE);
    map.put(KEY_PRODUCTION_YEAR, KEY_PRODUCTION_YEAR);
    map.put(KEY_COLUMN_DURATION, KEY_COLUMN_DURATION);
    map.put(KEY_ACTION, KEY_ACTION);
    map.put(BaseColumns._ID, "rowid AS " +
            BaseColumns._ID);
    map.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID, "rowid AS " +
            SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID);
    map.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, "rowid AS " +
            SearchManager.SUGGEST_COLUMN_SHORTCUT_ID);
    return map;
  }
...

이전 예에서 SUGGEST_COLUMN_INTENT_DATA_ID 필드의 매핑을 확인하세요. 이는 URI에서 이 행의 데이터에 고유한 콘텐츠를 가리키는 부분입니다. URI의 마지막 부분은 콘텐츠가 저장되는 위치를 설명합니다. URI의 첫 번째 부분은 테이블의 모든 행에 공통적인 경우 추천 검색어 처리 섹션에 설명된 대로 searchable.xml 파일에서 android:searchSuggestIntentData 속성으로 설정됩니다.

URI의 첫 번째 부분이 테이블의 행마다 다른 경우 해당 값을 SUGGEST_COLUMN_INTENT_DATA 필드와 매핑합니다. 사용자가 이 콘텐츠를 선택하면 실행되는 인텐트에서 SUGGEST_COLUMN_INTENT_DATA_IDandroid:searchSuggestIntentData 속성 또는 SUGGEST_COLUMN_INTENT_DATA 필드 값의 조합으로부터 인텐트 데이터를 제공합니다.

추천 검색어 데이터 제공

콘텐츠 제공자를 구현하여 Android TV 검색 대화상자에 추천 검색어를 반환합니다. 시스템은 문자가 입력될 때마다 query() 메서드를 호출하여 콘텐츠 제공자에 추천 항목을 쿼리합니다. query() 구현에서 콘텐츠 제공자는 추천 데이터를 검색하고 추천에 지정된 행을 가리키는 Cursor를 반환합니다.

Kotlin

fun query(uri: Uri, projection: Array<String>, selection: String, selectionArgs: Array<String>,
        sortOrder: String): Cursor {
    // Use the UriMatcher to see what kind of query we have and format the db query accordingly
    when (URI_MATCHER.match(uri)) {
        SEARCH_SUGGEST -> {
            Log.d(TAG, "search suggest: ${selectionArgs[0]} URI: $uri")
            if (selectionArgs == null) {
                throw IllegalArgumentException(
                        "selectionArgs must be provided for the Uri: $uri")
            }
            return getSuggestions(selectionArgs[0])
        }
        else -> throw IllegalArgumentException("Unknown Uri: $uri")
    }
}

private fun getSuggestions(query: String): Cursor {
    val columns = arrayOf<String>(
            BaseColumns._ID,
            VideoDatabase.KEY_NAME,
            VideoDatabase.KEY_DESCRIPTION,
            VideoDatabase.KEY_ICON,
            VideoDatabase.KEY_DATA_TYPE,
            VideoDatabase.KEY_IS_LIVE,
            VideoDatabase.KEY_VIDEO_WIDTH,
            VideoDatabase.KEY_VIDEO_HEIGHT,
            VideoDatabase.KEY_AUDIO_CHANNEL_CONFIG,
            VideoDatabase.KEY_PURCHASE_PRICE,
            VideoDatabase.KEY_RENTAL_PRICE,
            VideoDatabase.KEY_RATING_STYLE,
            VideoDatabase.KEY_RATING_SCORE,
            VideoDatabase.KEY_PRODUCTION_YEAR,
            VideoDatabase.KEY_COLUMN_DURATION,
            VideoDatabase.KEY_ACTION,
            SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID
    )
    return videoDatabase.getWordMatch(query.toLowerCase(), columns)
}

Java

@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
        String sortOrder) {
    // Use the UriMatcher to see what kind of query we have and format the db query accordingly
    switch (URI_MATCHER.match(uri)) {
        case SEARCH_SUGGEST:
            Log.d(TAG, "search suggest: " + selectionArgs[0] + " URI: " + uri);
            if (selectionArgs == null) {
                throw new IllegalArgumentException(
                        "selectionArgs must be provided for the Uri: " + uri);
            }
            return getSuggestions(selectionArgs[0]);
        default:
            throw new IllegalArgumentException("Unknown Uri: " + uri);
    }
}

private Cursor getSuggestions(String query) {
    query = query.toLowerCase();
    String[] columns = new String[]{
        BaseColumns._ID,
        VideoDatabase.KEY_NAME,
        VideoDatabase.KEY_DESCRIPTION,
        VideoDatabase.KEY_ICON,
        VideoDatabase.KEY_DATA_TYPE,
        VideoDatabase.KEY_IS_LIVE,
        VideoDatabase.KEY_VIDEO_WIDTH,
        VideoDatabase.KEY_VIDEO_HEIGHT,
        VideoDatabase.KEY_AUDIO_CHANNEL_CONFIG,
        VideoDatabase.KEY_PURCHASE_PRICE,
        VideoDatabase.KEY_RENTAL_PRICE,
        VideoDatabase.KEY_RATING_STYLE,
        VideoDatabase.KEY_RATING_SCORE,
        VideoDatabase.KEY_PRODUCTION_YEAR,
        VideoDatabase.KEY_COLUMN_DURATION,
        VideoDatabase.KEY_ACTION,
        SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID
    };
    return videoDatabase.getWordMatch(query, columns);
}
...

매니페스트 파일에서 콘텐츠 제공업체는 특수하게 처리됩니다. 활동으로 태그되지 않고 <provider>로 설명됩니다. 제공자는 android:authorities 속성을 포함하여 콘텐츠 제공자의 네임스페이스를 시스템에 알립니다. 또한 Android 글로벌 검색에서 반환된 결과를 사용할 수 있도록 android:exported 속성을 "true"로 설정해야 합니다.

<provider android:name="com.example.android.tvleanback.VideoContentProvider"
    android:authorities="com.example.android.tvleanback"
    android:exported="true" />

추천 검색어 처리

추천 검색어 설정을 구성하려면 앱에 res/xml/searchable.xml 파일을 포함해야 합니다.

res/xml/searchable.xml 파일에 android:searchSuggestAuthority 속성을 포함하여 콘텐츠 제공업체의 네임스페이스를 시스템에 알립니다. 이 값은 AndroidManifest.xml 파일에서 <provider> 요소의 android:authorities 속성에 지정하는 문자열 값과 일치해야 합니다.

애플리케이션의 이름인 라벨도 포함합니다. 시스템 검색 설정은 검색 가능한 앱을 열거할 때 이 라벨을 사용합니다.

또한 searchable.xml 파일에는 값이 "android.intent.action.VIEW" android:searchSuggestIntentAction를 포함하여 맞춤 추천을 제공하기 위한 인텐트 작업을 정의해야 합니다. 이는 다음 섹션에 설명된 대로 검색어를 제공하기 위한 인텐트 작업과 다릅니다. 추천을 위한 인텐트 작업을 선언하는 다른 방법은 인텐트 작업 선언을 참고하세요.

앱은 인텐트 작업과 함께 개발자가 android:searchSuggestIntentData 속성을 사용하여 지정하는 인텐트 데이터를 제공해야 합니다. 콘텐츠를 가리키는 URI의 첫 번째 부분으로, 해당 콘텐츠의 매핑 테이블에 있는 모든 행에 공통된 URI 부분을 설명합니다. 각 행에 고유한 URI의 부분은 열 식별 섹션에 설명된 대로 SUGGEST_COLUMN_INTENT_DATA_ID 필드를 사용하여 설정됩니다. 추천을 위한 인텐트 데이터를 선언하는 다른 방법은 인텐트 데이터 선언을 참고하세요.

android:searchSuggestSelection=" ?" 속성은 query() 메서드의 selection 매개변수로 전달되는 값을 지정합니다. 물음표 (?) 값이 쿼리 텍스트로 대체됩니다.

마지막으로 값이 "true" android:includeInGlobalSearch 속성도 포함해야 합니다. 다음은 searchable.xml 파일의 예입니다.

<searchable xmlns:android="http://schemas.android.com/apk/res/android"
    android:label="@string/search_label"
    android:hint="@string/search_hint"
    android:searchSettingsDescription="@string/settings_description"
    android:searchSuggestAuthority="com.example.android.tvleanback"
    android:searchSuggestIntentAction="android.intent.action.VIEW"
    android:searchSuggestIntentData="content://com.example.android.tvleanback/video_database_leanback"
    android:searchSuggestSelection=" ?"
    android:searchSuggestThreshold="1"
    android:includeInGlobalSearch="true">
</searchable>

검색어 처리

열 식별 섹션에 설명된 대로 검색 대화상자에 앱 열 중 하나의 값과 일치하는 단어가 있으면 시스템에서 ACTION_SEARCH 인텐트를 실행합니다. 인텐트를 처리하는 앱의 활동은 저장소에서 값에 지정된 단어가 있는 열을 검색하고 이러한 열이 있는 콘텐츠 항목의 목록을 반환합니다. 다음 예와 같이 AndroidManifest.xml 파일에서 ACTION_SEARCH 인텐트를 처리하는 활동을 지정합니다.

...
  <activity
      android:name="com.example.android.tvleanback.DetailsActivity"
      android:exported="true">

      <!-- Receives the search request. -->
      <intent-filter>
          <action android:name="android.intent.action.SEARCH" />
          <!-- No category needed, because the Intent will specify this class component -->
      </intent-filter>

      <!-- Points to searchable meta data. -->
      <meta-data android:name="android.app.searchable"
          android:resource="@xml/searchable" />
  </activity>
...
  <!-- Provides search suggestions for keywords against video meta data. -->
  <provider android:name="com.example.android.tvleanback.VideoContentProvider"
      android:authorities="com.example.android.tvleanback"
      android:exported="true" />
...

활동은 또한 searchable.xml 파일을 참조하여 검색 가능한 구성을 설명해야 합니다. 글로벌 검색 대화상자를 사용하려면 매니페스트에서 검색어를 받아야 하는 활동을 설명해야 합니다. 또한 매니페스트는 searchable.xml 파일에 설명된 대로 정확히 <provider> 요소를 설명해야 합니다.

세부정보 화면에 표시되는 앱의 딥 링크

추천 검색어 처리 섹션에 설명된 대로 검색 구성을 설정하고 열 식별 섹션에 설명된 대로 SUGGEST_COLUMN_TEXT_1, SUGGEST_COLUMN_PRODUCTION_YEAR, SUGGEST_COLUMN_DURATION 필드를 매핑하면 콘텐츠의 시청 작업 딥 링크가 사용자가 검색결과를 선택할 때 실행되는 세부정보 화면에 표시됩니다.

세부정보 화면에 표시되는 딥 링크

사용자가 세부정보 화면에서 **사용 가능** 버튼으로 식별되는 앱 링크를 선택하면 시스템은 searchable.xml 파일에서 값이 "android.intent.action.VIEW" android:searchSuggestIntentAction로 설정된 ACTION_VIEW를 처리하는 활동을 실행합니다.

활동을 실행하도록 맞춤 인텐트를 설정할 수도 있습니다. Leanback 샘플 앱에서 이를 확인할 수 있습니다. 샘플 앱은 자체 LeanbackDetailsFragment를 실행하여 선택한 미디어의 세부정보를 표시합니다. 앱에서는 미디어를 즉시 재생하는 활동을 실행하여 사용자가 한두 번 더 클릭하는 것을 줄입니다.

검색 동작

Android TV의 홈 화면과 앱 내에서 검색을 할 수 있습니다. 이 두 경우의 검색결과는 서로 다릅니다.

홈 화면에서 검색

사용자가 홈 화면에서 검색하면 첫 번째 결과가 항목 카드에 표시됩니다. 콘텐츠를 재생할 수 있는 앱이 있는 경우 카드 하단에 각 앱의 링크가 표시됩니다.

TV 검색결과 재생

프로그래매틱 방식으로 앱을 항목 카드에 배치할 수 없습니다. 재생 옵션으로 포함하려면 앱의 검색결과가 검색된 콘텐츠의 제목, 연도, 기간과 일치해야 합니다.

카드 아래에 더 많은 검색결과가 표시될 수도 있습니다. 알림을 보려면 사용자가 리모컨을 누르고 아래로 스크롤해야 합니다. 각 앱의 결과는 별도의 행에 표시됩니다. 행 순서를 제어할 수 없습니다. 시청 작업을 지원하는 앱이 먼저 나열됩니다.

TV 검색결과

앱에서 검색

사용자는 리모컨이나 게임 패드 컨트롤러에서 마이크를 시작하여 앱 내에서 검색을 시작할 수도 있습니다. 검색결과는 앱의 콘텐츠 상단에 단일 행으로 표시됩니다. 앱에서는 자체 글로벌 검색 제공자를 사용하여 검색결과를 생성합니다.

TV 인앱 검색결과

자세히 알아보기

TV 앱을 검색하는 방법에 관한 자세한 내용은 앱에 Android 검색 기능 통합검색 기능 추가를 참고하세요.

SearchFragment로 인앱 검색 환경을 맞춤설정하는 방법에 관한 자세한 내용은 TV 앱 내 검색을 참고하세요.