실시간 Android WhatsApp 클론 앱 개발하기

Jaewoong Eum·2022년 8월 9일
8
post-thumbnail

원문은 Build a Real-Time Android WhatsApp Clone With Jetpack Compose에서 확인하실 수 있습니다.

이번 포스트에서는 Jetpack Compose 및 Stream의 Compose Chat SDK를 사용하여 실시간 Android WhatsApp 프로젝트를 구축하는 방법을 살펴봅니다.

또한 전반적인 아키텍처, 각 레이어, WhatsApp-Clone-Compose 프로젝트에 사용되는 테마 스타일링 등에 대하여 살펴봅니다.

시작하기에 앞서, 아래 명령어를 터미널에 입력하여 WhatsApp-Clone-Compose 프로젝트를 내려받고, Android Studio를 이용하여 프로젝트를 살펴보시면 블로그 내용을 이해하는 데 도움이 될 수 있습니다. 혹은 GitHub에서 직접 프로젝트를 다운로드 받으셔도 됩니다.

git clone https://github.com/GetStream/WhatsApp-clone-compose.git

WhatsApp Clone Compose를 빌드 하면 디바이스에 다음과 같은 결과가 나오게 됩니다.

App Architecure

WhatsApp Clone Compose 프로젝트는 고품질 앱을 빌드하기 위해 구글에서 권장하는 구글의 앱 아키텍처 가이드라인을 따르고 있습니다. 이 포스트에서는 해당 프로젝트의 앱 아키텍처가 어떻게 설계되었고, 비즈니스 데이터가 서로 다른 계층 간에 어떻게 흐르는지 살펴봅니다.

아래 그림에서 볼 수 있듯이 각 계층에는 서로 매우 느슨하게 연결된(loosely coupled) 각자의 컴포넌트들이 있습니다. 화살표는 구성 요소가 방향을 따라 대상 구성 요소에 종속됨을 의미합니다.

근본적으로, 아키텍처는 다음과 같이 UI layerData layer 2개의 레이어로 구성됩니다.

각 레이어는 아래 정의된 것과 같이 서로 다른 책임을 가지고 있습니다.

  • 각 레이어는 unidirectional event/data flow 원칙을 따르고 있습니다. UI 계층은 사용자 이벤트를 데이터 계층으로 내보내고 데이터 계층은 데이터를 다른 계층에 stream 형태로 노출합니다.
  • 데이터 계층은 다른 계층과 독립적으로 동작하도록 설계되었으며 순수해야 합니다. 즉, 다른 계층에 종속되지 않습니다.

아키텍처의 레이어끼리 느슨하게 결합되어 있기 때문에, 구성 요소의 재사용성과 앱의 확장성을 높일 수 있습니다.

이제 각 레이어가 어떻게 작동하는지 살펴봅니다.

UI Layer

WhatsApp Clone Compose는 UI 요소를 그리기 위하여 100% Jetpack Compose로 개발되었으며, ViewModel에서 발생하는 UI 상태를 관찰하여 화면을 구성합니다. ViewModel은 디바이스 회전 시 UI 상태를 유지하고 데이터를 복원하는 역할을 수행합니다.

ViewModel은 데이터 레이어의 비즈니스 데이터를 UI 상태로 변환하고 UI 요소는 성공, 로드 또는 오류 대한 UI 상태에 따라 화면을 구성합니다.

UI 상태는 single source-of-truth 원칙에 따라 비즈니스 데이터 또는 예외를 처리하므로 UI 레이어에서 UI 상태에 따라 UI 요소를 그리는 방법에 집중할 수 있습니다.

Data Layer

Data 레이어는 public 인터페이스를 다른 계층에 노출하는 리포지토리로 구성됩니다. 리포지토리에는 로컬 데이터베이스에서 데이터 쿼리 및 네트워크에서 원격 데이터 요청 같이 주로 백그라운드 스레드에서 실행되는 비즈니스 로직이 포함됩니다.

리포지토리의 인터페이스를 노출하여 다른 계층에서 Kotlin의 Flow와 같은 stream으로 비즈니스 데이터를 관찰할 수 있습니다.

리포지토리에는 로컬 데이터베이스 및 네트워크와 같은 여러 데이터 원본이 있습니다. 다양한 데이터 출처로부터 single source-of-truth 원칙을 보장하기 위해 리포지토리는 위의 그림과 같이 오프라인 데이터를 우선적으로 비즈니스 로직 소스를 구현합니다.

Modularization

WhatsApp Clone Compose는 앱 개발성을 향상시키기 위하여 아래와 같이 멀티 모듈 전략을 사용하였습니다.

  • 재사용성: 재사용 가능한 코드를 적절하게 모듈화하면 코드 재사용성의 기회가 활성화되고 다른 모듈 끼리의 코드 접근성을 제한할 수 있습니다.

  • 병렬 빌드: 각 모듈을 병렬로 실행할 수 있어 빌드 시간이 단축됩니다.

  • 팀 집중 분산: 각 개발자 팀은 팀 전용 모듈을 할당받고 자체 모듈에 집중할 수 있습니다.

모듈화를 사용하면 다른 기능에 종속되지 않고 기능 빌드하고 테스트 코드를 독립적으로 작성할 수 있습니다. 결과적으로 모듈 간의 느슨한 결합과 높은 응집력을 얻을 수 있습니다. 즉, 각 모듈은 자체 도메인 논리에 대해서만 명확하게 정의된 책임을 가집니다.

Theming with Jetpack Compose

WhatsApp Clone Compose 프로젝트에는 core-designsystem 모듈에 테마 구성 요소가 있습니다. 이 모듈에는 UI 기능 모듈에서 사용되는 재사용 가능한 구성 요소와 Theme, Background, Typography와 같은 테마 정의가 포함됩니다.

이번 포스트에서는 WhatsApp Clone Compose가 전체 UI 요소의 배경 및 테마 스타일을 지정하는 방법을 살펴봅니다.

Background

스타일링 배경은 애플리케이션 디자인의 중요한 부분 중 하나 입니다. UI 요소가 일관된 배경색을 갖도록 하기 위해 WhatsApp Clone ComposeWhatsAppCloneBackground라고 하는 자체 배경 구성 가능을 구현했습니다.

@Immutable
data class BackgroundTheme(
  val color: Color = Color.Unspecified,
  val tonalElevation: Dp = Dp.Unspecified
)

val LocalBackgroundTheme = staticCompositionLocalOf { BackgroundTheme() }

@Composable
fun WhatsAppCloneBackground(
  modifier: Modifier = Modifier,
  content: @Composable () -> Unit
) {
  val color = LocalBackgroundTheme.current.color
  val tonalElevation = LocalBackgroundTheme.current.tonalElevation
  Surface(
    color = if (color == Color.Unspecified) Color.Transparent else color,
    tonalElevation = if (tonalElevation == Dp.Unspecified) 0.dp else tonalElevation,
    modifier = modifier.fillMaxSize()
  ) {
    CompositionLocalProvider(LocalAbsoluteTonalElevation provides 0.dp) {
      content()
    }
  }
}

BackgroundTheme에는 기본적으로 아래 두 가지 속성이 포함됩니다.

  • Color: Surface의 배경색을 결정하는 속성입니다.
  • Tonal Elevatio: Tonal elevation는 elevation의 수준에 따라 구성 요소를 서로 다르게 강조합니다. Elevation 수준이 +1에서 +5인 Surface는 앱 바 또는 메뉴와 같은 기본 색상을 기반으로 하는 색상 오버레이를 통해 색상이 결정됩니다. 자세한 내용은 Material Design 3 Color system을 확인하세요.

아래 예와 같이 구성 가능한 WhatsAppCloneBackground로 UI 요소를 래핑하면 composable 함수의 하위 UI들이 동일한 배경색을 갖게 됩니다.

WhatsAppCloneBackground {
  WhatsAppNavHost(
    navHostController = navHostController,
    composeNavigator = composeNavigator
  )
}

Theme

테마는 앱 디자인의 가장 필수적인 부분 중 하나이며 Jetpack Compose에서는 기존의 XML 기반의 프로젝트 보다 훨씬 쉽게 구현할 수 있습니다.

WhatsApp Clone Compose는 전체 UI 요소의 스타일을 지정하고 lightColorScemedarkColorSceme 메서드로 커스텀 컬러 스키마를 정의하기 위해 Material Theme를 사용합니다.

private val DarkWhatsAppColorScheme = darkColorScheme(
  primary = DARK_GREEN200,
  primaryContainer = DARK_GREEN300,
  secondary = GREEN500,
  background = DARK_GREEN300,
  tertiary = WHITE200,
  onTertiary = GRAY200
)

private val LightWhatsAppColorScheme = lightColorScheme(
  primary = GREEN500,
  primaryContainer = GREEN700,
  secondary = GREEN300,
  background = WHITE200,
  tertiary = WHITE200,
  onTertiary = GRAY200
)

또한 WhatsApp Clone Compose는 아래와 같이 WhatsAppCloneComposeTheme라는 자체 테마를 정의합니다.

/** Light Android background theme */
private val LightAndroidBackgroundTheme = BackgroundTheme(color = Color.White)

/** Dark Android background theme */
private val DarkAndroidBackgroundTheme = BackgroundTheme(color = DARK_GREEN300)

@Composable
fun WhatsAppCloneComposeTheme(
  darkTheme: Boolean = isSystemInDarkTheme(),
  content: @Composable () -> Unit
) {
  val colorScheme = if (darkTheme) DarkWhatsAppColorScheme else LightWhatsAppColorScheme
  val backgroundTheme = if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme

  CompositionLocalProvider(
    LocalBackgroundTheme provides backgroundTheme
  ) {
    MaterialTheme(
      colorScheme = colorScheme,
      typography = Typography,
      content = content
    )
  }
}

색상 테마는 사용자 장치가 다크 모드 여부에 따라 다른 컬러 스키마와 배경색을 적용됩니다.

아래 예시와 같이 UI 요소를 WhatsAppCloneComposeTheme로 래핑하면 정의된 컬러 스키마 따라서 UI 요소에 색상이 적용된 결과를 볼 수 있습니다.

WhatsAppCloneComposeTheme {
  ..

  WhatsAppCloneBackground {
    WhatsAppNavHost(
      navHostController = navHostController,
      composeNavigator = composeNavigator
    )
  }
}

특정 UI 요소에 다른 색상을 적용해야 하는 경우, 아래와 같이 컬러 스키마에 정의된 색상 값을 가져와 사용할 수 있습니다.

TabRow(
  backgroundColor = MaterialTheme.colorScheme.primary,
  ...
)

그 결과 아래 이미지와 같이 UI 요소에 사용자 테마가 적용된 것을 확인할 수 있습니다.

다음으로 실시간 WhatsApp 채팅 기능을 개발해 보도록 하겠습니다.

Getting Started With the Stream Chat SDK

WhatsApp Clone Compose는 Stream의 Compose Chat SDK로 채팅 기능을 구축했습니다. Stream Chat SDK는 수천 개의 서로 다른 앱에서 수십억 명의 글로벌 최종 사용자가 사용하고 있는 고성능 채팅 솔루션을 제공합니다.

GitHub 계정으로 무료 스트림 채팅 평가판에 빠르게 가입할 수 있습니다. 소규모 팀 및 개인의 경우에는 Startup Plan을 무료로 사용할 수 있는 Maker Account를 신청할 수 있습니다.

계정을 생성하셨다면 아래와 같이 스트림 대시보드에서 Create App 버튼을 눌러 새로운 프로젝트를 만듭니다.

프로젝트를 만든 후 API Key를 기록해 둡니다.

개발 편의상 인증 기능을 비활성화 시킵니다. Overview 페이지에서 스크롤을 내리면 Authentication 항목이 나오고, Disable Auth Checks를 아래와 같이 활성화시킨 후 Submit 버튼을 눌러서 저장합니다.

Gradle Setup

채팅 기능을 구현하기 전에 Jetpack Compose용 Stream SDK를 프로젝트에 추가합니다. app 모듈의 build.gradle 파일에 대해 아래 종속성을 추가합니다.

// settings.gradle
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url "https://jitpack.io" }
    }
}

// app module's build.gradle
dependencies { 
    // Stream Chat Android SDK
    implementation "io.getstream:stream-chat-android-compose:5.7.0" 
}

Stream SDK는 오픈 소스 프로젝트이므로 GitHub에서 모든 소스 코드, 커밋 이력, 릴리스 내용을 볼 수 있습니다.

참고: Android Stream Chat을 처음 사용하는 경우 Compose Chat Messaging Tutorial도 참조할 수 있습니다.

Initialize ChatClient With App Startup

ChatClient는 사용자를 Stream 서버에 연결 및 연결 해제하거나 메시지를 보내고 응답하는 것과 같은 모든 하위 수준 채팅 작업의 기본 진입점입니다.

WhatsApp Clone ComposeApp Startup으로 ChatClient를 초기화하고 개발 편의를 위해 user connection도 한 번에 수행합니다. 인증이 비활성화되어 있으므로 developer 토큰을 사용하여 user connection을 수행합니다.

class StreamChatInitializer : Initializer<Unit> {

  override fun create(context: Context) {
    val logLevel = if (BuildConfig.DEBUG) ChatLogLevel.ALL else ChatLogLevel.NOTHING
    val offlinePluginFactory = StreamOfflinePluginFactory(
      config = Config(
        backgroundSyncEnabled = true,
        userPresence = true,
        persistenceEnabled = true,
        uploadAttachmentsNetworkType = UploadAttachmentsNetworkType.NOT_ROAMING
      ),
      appContext = context
    )
    val chatClient = ChatClient.Builder(**YOUR API KEY**, context)
      .withPlugin(offlinePluginFactory)
      .logLevel(logLevel)
      .build()

    val user = User(
      id = "stream",
      name = "stream",
      image = "https://placekitten.com/200/300"
    )

    val token = chatClient.devToken(user.id)
    chatClient.connectUser(user, token).enqueue()
  }

  override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

App Startup을 사용하기 때문에 AndroidManifest.xml 파일 내에 추가한 initializer를 정의해야 하며 앱이 시작될 때 실행합니다.

<provider
  android:name="androidx.startup.InitializationProvider"
  android:authorities="${applicationId}.androidx-startup"
  android:exported="false"
  tools:node="merge">
  <meta-data
    android:name="io.getstream.whatsappclone.chats.initializer.StreamChatInitializer"
    android:value="androidx.startup" />
</provider>

다음으로는 채널 리스트 화면을 구현해 보도록 하겠습니다.

Build a Channel List Screen

Stream SDK는 쉽게 채팅 화면을 구축할 수 있는 high-level UI 컴포넌트들을 제공합니다. ChannelsScreen composable 함수를 이용하면 채널 목록 화면을 쉽게 구현할 수 있습니다.

ChatTheme {
  ChannelsScreen(
    isShowingHeader = false,
    onItemClick = { channel ->
      // navigate to messages screen
    }
  )
}

위의 예에서 볼 수 있듯이 primary color, typography, shapes과 같은 채팅 UI 컴포넌트의 스타일을 지정하는 ChatTheme으로 ChannelsScreen을 래핑해야 합니다.

또한 사용자가 채널 항목을 클릭할 때 'onItemClick' 람다 매개변수를 사용하여 메시지 목록 화면을 표시하도록 화면을 이동하거나 액티비티를 실행합니다.

위와 같이 구현하면 아래와 같은 결과를 볼 수 있습니다.

Build a Message List Screen

아래이 같은 MessagesScreen composable 함수로 메시지 목록 화면을 쉽게 구현할 수 있습니다.

ChatTheme {
  MessagesScreen(
    channelId = channelId,
    showHeader = false,
    onBackPressed = { composeNavigator.navigateUp() }
  )
}

다음으로 이미지 및 이름과 같은 채널 정보가 포함된 TopAppBar를 구현해야 합니다. 채널 정보를 얻으려면 아래와 같이 ChatClient를 사용하여 네트워크에서 데이터를 가져와야 합니다.

@HiltViewModel
class WhatsAppMessagesViewModel @Inject constructor(
  @Dispatcher(WhatsAppDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
  private val chatClient: ChatClient
) : ViewModel() {
  private val messageMutableUiState =
    MutableStateFlow<WhatsAppMessageUiState>(WhatsAppMessageUiState.Loading)
  val messageUiSate: StateFlow<WhatsAppMessageUiState> = messageMutableUiState

  private fun fetchChannel(channelId: String) {
    viewModelScope.launch(ioDispatcher) {
      val result = chatClient.channel(channelId).watch().await()
      result.onSuccess {
        messageMutableUiState.value = WhatsAppMessageUiState.Success(result.data())
      }.onError {
        messageMutableUiState.value = WhatsAppMessageUiState.Error
      }
    }
  }
}

Hilt와 같은 의존성 주입 도구를 사용하여 ChatClient를 ViewModel에 주입하거나 다음 메서드를 사용하여 인스턴스를 가져올 수 있습니다. ChatClient.instance().

네트워크 호출을 통해 채널 정보를 가져온 후 WhatsAppMessagesViewModel은 결과를 아래와 같이 정의된 UI 상태로 변환합니다.

sealed interface WhatsAppMessageUiState {
  data class Success(val channel: Channel) : WhatsAppMessageUiState
  object Loading : WhatsAppMessageUiState
  object Error : WhatsAppMessageUiState
}

WhatsApp Clone Compose는 ViewModel에서 처리할 이벤트로 각 사용자 상호 작용을 모델링합니다.

@HiltViewModel
class WhatsAppMessagesViewModel @Inject constructor(
 ..
) : ViewModel() {

 ..
 
 fun handleEvents(whatsAppMessageEvent: WhatsAppMessageEvent) {
    when (whatsAppMessageEvent) {
      is WhatsAppMessageEvent.FetchChannel -> fetchChannel(whatsAppMessageEvent.channelId)
    }
  }
}

sealed interface WhatsAppMessageEvent {
  class FetchChannel(val channelId: String) : WhatsAppMessageEvent
}

따라서 UI 요소에 handleEvents 메서드를 노출하고 아래 예와 같이 API 표면을 크게 줄일 수 있습니다.


@Composable
fun WhatsAppMessages(
  channelId: String,
  composeNavigator: AppComposeNavigator,
  whatsAppMessagesViewModel: WhatsAppMessagesViewModel
) {
  LaunchedEffect(key1 = channelId) {
    whatsAppMessagesViewModel.handleEvents(
      WhatsAppMessageEvent.FetchChannel(channelId)
    )
  }
..
)

마지막으로 성공, 로드 및 오류와 같이 ViewModel에서 가져온 UI 상태에 따라 composable 함수를 실행할 수 있습니다.

@Composable
fun WhatsAppMessageTopBar(
  viewModel: WhatsAppMessagesViewModel,
  composeNavigator: AppComposeNavigator
) {
  val messageUiState by viewModel.messageUiSate.collectAsState()

  TopAppBar(
    modifier = Modifier.fillMaxWidth(),
    backgroundColor = MaterialTheme.colorScheme.primary,
    elevation = 0.dp
  ) {
    ..
    
    WhatsAppMessageUserInfo(messageUiState = messageUiState)
    
    ..
 }
 
@Composable
private fun WhatsAppMessageUserInfo(
  messageUiState: WhatsAppMessageUiState
) {
  when (messageUiState) {
    WhatsAppMessageUiState.Loading -> WhatsAppLoadingIndicator()
    WhatsAppMessageUiState.Error -> Unit
    is WhatsAppMessageUiState.Success -> {
      GlideImage(
        modifier = Modifier
          .size(32.dp)
          .clip(CircleShape),
        imageModel = messageUiState.channel.image,
        previewPlaceholder = io.getstream.whatsappclone.designsystem.R.drawable.placeholder
      )

      Text(
        modifier = Modifier.padding(start = 12.dp),
        text = messageUiState.channel.name,
        color = MaterialTheme.colorScheme.tertiary,
        style = MaterialTheme.typography.bodyLarge
      )
    }
  }
}  

참고: TopAppBar에 네트워크 이미지를 로드하기 위해 WhatsApp Clone ComposeLandscapist를 사용하였습니다. Landscapist는 Jetpack Compose에서 Glide, CoilFresco를 이용하여 이미지를 로팅할 수 있도록 합니다.

최종적으로 아래와 같이 메세지 화면이 완성됩니다.

마무리

이 튜토리얼에서는 WhatsApp Clone Compose 프로젝트의 전체 아키텍처와 Stream의 Compose Chat SDK를 사용하여 어떻게 채팅 기능을 개발하는지 살펴보았습니다.

Compose Chat SDK에 대해 자세히 알아보려면 Compose Chat Tutorial를 살펴보거나 GitHub에서 아래의 오픈 소스 프로젝트를 확인하세요.

향후 안드로이드와 관련된 다양한 기술 관련 포스트는 Stream 블로그에 가장 먼저 연재될 예정이며, 추후 velog에 번역본이 포스팅 될 예정입니다.

즐거운 코딩 되시길 바랍니다!

작성자 엄재웅 (skydoves)

profile
http://github.com/skydoves

0개의 댓글