텃텃
에선 작물별 레시피를 제공하기로 했다. (기획 의도)
그리고 Flow
를 공부하고 적용해보는 것이 나의 목표 중 하나였기에 크롤링 데이터를 Flow로 제공하고 싶었고 적절한 방법을 고민해보았다.
레시피 제공 기능은
만개의레시피에서 작물 별 레시피 10개 크롤링
작물 정보, 작물 상세 페이지에서 레시피 데이터 매핑
클릭 시, WebView
로 레시피 페이지 띄우기
크롤링을 위한 라이브러리는 Jsoup를 사용했다.
Java의 HTML 파싱 라이브러리 중 Jsoup 이외의 다른 대안을 찾을 수 없었다.
build.gradle.kts
dependencies {
implementation('org.jsoup:jsoup:${최신 버전}')
}
AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
//크롤링하려는 페이지 URL이 'https'가 아니라면 application 태그 내에 추가
android:usesCleartextTraffic="true"
이렇게 사전 준비가 끝났다면 단계별로 시작해보자
Recipe.kt
data class Recipe(
val title: String,
val imgUrl: String,
val link: String
)
내가 제공할 레시피 데이터는 제목과 이미지, 그리고 클릭 시 이동할 Web 링크 url이 필요하다.
CropsInfoRepository.kt
interface CropsInfoRepository {
fun getCropsRecipes(keyword: String): Flow<List<Recipe>>
}
난 작물별 레시피를 제공하기 위해 keyword
로 다른 URL에서 데이터를 크롤링할 것이다.
DataModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
internal abstract fun bindCropsInfoRepository(
cropsInfoRepository: CropsInfoRepositoryImpl
) : CropsInfoRepository
}
Hilt
를 사용했기 때문에 Repository 종속성을 주입하기 위한 모듈을 만들어준다.
크롤링할 페이지의 Url은 q
라는 쿼리를 사용해 데이터를 불러온다. 그래서 내 작물별 keyword
를 여기에 넣어주기로 했다.
이제 개발자 도구
를 켜서 원하는 데이터가 어디있는지 찾아보자
그리고 해당 HTML이 어떻게 구성되는지 파악해보자
ul
태그 하위의 각 li
를 가져온다.
각 li
의 첫 div의 a의 href
그리고 a>img이 src
가 필요하다.
각 li
두 번째 div의 첫 div의 text
가 필요하다.
CropsInfoRepositoryImpl.kt
class CropsInfoRepositoryImpl @Inject constructor(): CropsInfoRepository {
override fun getCropsRecipes(keyword: String): Flow<List<Recipe>> = flow {
val crawlingUrl = "${CRAWLING_BASE_URL}/recipe/list.html?q=${keyword}"
val doc = withContext(Dispatchers.IO) { Jsoup.connect(crawlingUrl).get() }
val recipes = doc.select(".common_sp_list_ul.ea4 li").take(10).map { element ->
val aTag = element.select(".common_sp_thumb a")
val imgTag = aTag.select("img")
val divTag = element.select(".common_sp_caption_tit.line2")
Recipe(
title = divTag.text(),
imgUrl = imgTag.attr("src"),
link = aTag.attr("href")
)
}
emit(recipes)
}.flowOn(Dispatchers.IO)
}
flow
블록을 만들고 keyword
를 바탕으로 크롤링할 Url을 선언한다.
IO 환경에서 Jsoup
과 연결을 먼저 수행하기 위해 withContext
를 이용한다.
take
과 map
중간 연산자로 10개의 Recipe을 반환하는 flow를 만든다.
10개의 List<Recipe>
를 방출하고 flowOn
으로 IO 환경에서 작업하게 한다.
네트워크 작업에 최적화된 환경은 IO 환경이다.
UiState
sealed interface CropsRecipeUiState {
data object Loading : CropsRecipeUiState
data class Success(
val recipes: List<Recipe>
) : CropsRecipeUiState
CropsInfoDetailViewModel.kt
@HiltViewModel
class CropsInfoDetailViewModel @Inject constructor(
val cropsInfoRepo: CropsInfoRepository
): BaseViewModel() {
val recipeUiState: StateFlow<CropsRecipeUiState>
= cropsInfoRepo
.getCropsRecipes(cropsInfo.value.name)
.map(CropsRecipeUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = CropsRecipeUiState.Loading
)
...
}
CropsInfoDetailScreen.kt
//ViewModel을 생성하는 상위 Composable
val recipeUiState by viewModel.recipeUiState.collectAsStateWithLifecycle()
...
//UiState을 매개변수로 받는 하위 Composable
when (recipeUiState) {
CropsRecipeUiState.Loading -> {
item(span = { GridItemSpan(maxLineSpan) }) {
TutTutLoadingScreen(Modifier.height(300.dp))
}
}
is CropsRecipeUiState.Success -> {
items(
count = recipeUiState.recipes.size,
key = { it }
) { index ->
RecipeItem(
recipe = recipeUiState.recipes[index],
isLeftItem = index % 2 == 0,
onItemClick = { onRecipe(recipeUiState.recipes[index].link) }
)
}
}
}
map
연산자를 이용해 Flow<List<Recipe>>
를 Flow<UiState>
으로 변형한다.
stateIn
연산자를 이용해 Flow<UiState>
을 StateFlow<UiState>
으로 변환한다.
collectAsStateWithLifecycle()
로 StateFlow<UiState>
을 State<UiState>
으로 수집한다.
하위 Composable에서 UiState
를 받아 상태에 따라 UI에 데이터를 매핑한다.
해당 방식으로 Flow
를 이용한 크롤링 데이터 흐름을 만들고, 각 작물별 필요한 데이터를 받아 사용할 수 있다.
WebView
를 사용해 두 가지 페이지를 띄워야 했다.
작물별 전체 레시피 페이지
해당 작물 레시피 상세 페이지
그래서 작물 데이터용 모델에 이동할 Url을 저장하고 ViewModel에 주입해 사용하기로 했다.
CropsModel.kt
@Singleton
class CropsModel @Inject constructor() {
private val _observedCrops = MutableStateFlow(Crops())
val observedCrops: StateFlow<Crops> = _observedCrops
private val _recipeLink = MutableStateFlow("")
val recipeLink: StateFlow<String> = _recipeLink
fun observeCrops(crops: Crops) {
_observedCrops.value = crops
}
fun setRecipeLink(link: String) {
_recipeLink.value = link
}
}
RecipeWebViewModel.kt
@HiltViewModel
class RecipeWebViewModel @Inject constructor(
cropsModel: CropsModel
) : BaseViewModel() {
val crops = cropsModel.observedCrops
val link = cropsModel.recipeLink
}
CropsModel은 @SingleTon
으로 같은 인스턴스를 반환하도록 했다.
이제 Compose의 WebView
가 필요한데, android.webkit의 WebView
로 인스턴스를 생성하고 compose.ui의 AndroidView
로 화면위에 띄울 수 있다.
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun rememberWebView(url: String): WebView {
val context = LocalContext.current
val webView = remember {
WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = WebViewClient()
loadUrl(url)
}
}
return webView
}
리컴포지션 단계에서 WebView 인스턴스
를 새로 생성하지 않기 위해 remember
를 사용한다.
WebView
는 기본적으로 javaScriptEnabled=false
로 생성된다. 이것을 true
로 설정하면 띄우는 페이지의 javaScript가 동작하게 허용하고, 보안의 위험성이 있다고 경고한다.
하지만 만 개의 레시피
페이지에 javaScript 동작을 빼버리면 상당히 이상하게 보여져 true
로 설정했다.
보안 위험성 때문에
javaScriptEnabled=false
로 설정하는 것이 권장된다.
RecipeWebScreen.kt
@Composable
fun RecipeWebRoute(
modifier: Modifier = Modifier,
onBack: () -> Unit,
viewModel: RecipeWebViewModel = hiltViewModel()
) {
val crops by viewModel.crops.collectAsStateWithLifecycle()
val link by viewModel.link.collectAsStateWithLifecycle()
val webView = rememberWebView(url = "${CRAWLING_BASE_URL}${link}")
RecipeWebScreen(
modifier = modifier,
cropsName = crops.name,
webView = webView,
onBack = onBack
)
BackHandler {
if (webView.canGoBack()) webView.goBack()
else onBack()
}
}
@Composable
internal fun RecipeWebScreen(
modifier: Modifier,
cropsName: String,
webView: WebView,
onBack: () -> Unit
) {
Column(
modifier = modifier.fillMaxSize()
) {
TutTutTopBar(
title = "$cropsName ${stringResource(id = R.string.crops_recipe)}",
needBack = true,
onBack = onBack
)
AndroidView(
modifier = Modifier.weight(1f),
factory = { webView }
)
}
}
이렇게 생성한 WebView 인스턴스
를 AndroidView
의 factory에 사용하면 원하는 페이지를 띄울 수 있다.
여기서 중요한 점은 BackHandler
에서 webView
의 이전 페이지가 있다면, webView
이전 페이지로 돌아가고 이전 페이지가 없다면 앱의 이전 페이지로 돌아가게 만들었다.
그래서 기기의 뒤로 가기
버튼을 통해 띄운 페이지(만 개의 레시피)를 탐색할 수 있다.
대신 TopBar의 뒤로 가기 아이콘을 누른다면 바로 이전 페이지로 갈 수 있는 선택지도 제공한다.
크롤링
은 예전부터 구현해보고 싶은 기능이었다. 마침 텃텃에서 필요했고 Flow를 공부하며 즐겁게 적용했던 경험이었다.
레시피 크롤링은 나름 빠르게 작동하지만, Flow를 올바르게 사용하는지 혹은 더 개선할 방법이 없는지를 찾아봐야할 것 같다. 그래서 이후 버전에선 성능 평가를 통해 개선된 로직을 적용해보고 공유하려 한다.
클린 아키텍처 + Hilt + Compose를 사용하는 프로젝트에서 크롤링 기능이 필요한 사람에게 이 글이 도움이 됐으면 좋겠다!