WhatsApp 엔지니어로부터 Wear OS 앱을 대규모로 업그레이드한 방법에 대해 직접 들어보세요. 이 세션에서는 Material 3를 통합하고 Wear 위젯을 채택하는 데 필요한 아키텍처 및 UX 결정사항을 다룹니다.
팀이 앱을 업데이트하고 일관된 사용자 환경을 제공하기 위해 사용한 교훈, 패턴, 도구에 대한 직접적인 정보를 얻을 수 있습니다.
TransformingLazyColumn을 올바르게 사용한다는 것은 한 수준에서만 변환을 적용하고 Compose의 remember로 위치를 캐시하지 않는 것을 의미합니다.스크립트
안녕하세요.
저는 마틴입니다.
저는 수바그입니다.
저희는 Meta에서 Wear OS용 WhatsApp을 개발하는 팀의 엔지니어입니다.
최근에는 앱을 현대화했습니다.
그래서 오늘은 공유 코드베이스로 이전하고 그 위에 새로운 환경을 구축하면서 얻은 교훈을 공유하고자 합니다.
Wear OS의 WhatsApp이 흥미로운 이유는 휴대전화 없이도 작동한다는 점입니다.
연결되면 휴대전화와 마찬가지로 엔드 투 엔드 암호화가 적용되며 자체 데이터베이스와 서버 연결을 갖춘 독립적인 WhatsApp 기기로 작동합니다.
따라서 휴대전화가 범위를 벗어나도 Wi-Fi 또는 LTE를 통해 계속 작동합니다.
2023년에 Wear OS용 WhatsApp을 처음 출시했습니다.
이 공격은 Rust로 작성된 소형 휴대용 클라이언트를 통해 이루어졌습니다.
덕분에 프로덕션에 빠르게 적용할 수 있었습니다.
하지만 이는 WhatsApp의 두 번째 독립적인 구현이었기 때문에 모든 프로토콜, 암호화, 기능을 두 번 빌드해야 했습니다.
따라서 워치 앱은 항상 따라잡는 중이었습니다.
휴대성이 뛰어나다는 점은 배경을 처리하는 데도 어려움을 주었습니다.
클라이언트는 Android가 관리하려는 스레드, 타이머, 소켓을 자체적으로 처리했습니다.
Android에서는 백그라운드에 규칙이 있습니다.
잠자기 모드에서는 네트워크가 끊깁니다.
프로세스가 캐시되면 실행 시간이 제한되거나 실행 시간이 없습니다.
이를 극복하려면 서비스, 작업 또는 푸시가 필요합니다.
따라서 워치 앱은 이를 수동으로 연결해야 했습니다.
하지만 Android 코드베이스는 이미 이 작업을 실행하고 있습니다.
따라서 수십억 대의 기기에서 검증되고 이미 Android 규칙을 따르는 Android 코드베이스를 재사용하고 이전하기로 결정했습니다.
이렇게 하면 새 기능을 두 번 빌드할 필요가 없습니다.
목표는 간단했습니다. 비즈니스 로직은 유지하고 UI는 삭제하는 것이었습니다.
남은 것은 Kotlin 흐름을 API로 사용하는 헤드리스 라이브러리입니다.
하지만 이 정도 규모의 앱을 한 번에 정리할 수는 없습니다.
일부 모듈은 UI를 인프라와 혼합합니다.
다른 라이브러리는 Wear OS 앱에 필요하지 않은 기능에 종속됩니다.
그래서 우리는 두 가지 트랙으로 작업했습니다. 모듈화를 계속 진행하고, 아직 삭제할 수 없는 부분은 제거하거나 덮어썼습니다.
모듈화부터 시작해 보겠습니다.
빌드에서 코드를 제외하려면 간단한 '전화 앱인 경우' 문은 코드를 삭제하지 않으므로 작동하지 않습니다.
따라서 DI, 특히 선택적 바인딩과 멀티바인딩에 크게 의존합니다.
사내 DI를 사용하지만 Dagger가 제공하는 기능과 거의 일치합니다.
예시는 Dagger로 작성되어 있습니다.
선택적 결합(Dagger에서는 BindsOptionalOf)을 사용하면 종속 항목을 선택사항으로 선언할 수 있습니다(예: 백엔드 호출).
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}그런 다음 시계 빌드에 구현을 제공하는 모듈이 포함되어 있으면 삽입된 백엔드가 표시되고 호출이 작동합니다.
모듈을 생략하면 null이 되고 API에서 NotAllowed를 반환할 수 있습니다.
멀티바인딩은 동일하게 작동하지만 세트에 적용됩니다.
@Module
interface CoreModule {
@Multibinds
fun handlers(): Set<MessageHandler>
}
@Module
interface ReactionsModule {
@Binds @IntoSet
fun reactions(impl: ReactionHandler):
MessageHandler
}
class MessageDispatcher @Inject constructor(
private val handlers: Set<MessageHandler>,
)예를 들어 MessageHandler와 같이 multibinds를 사용하여 집합을 선언할 수 있으며, 그런 다음 각 기능 모듈은 into set multibinding을 사용하여 자체 핸들러를 추가할 수 있습니다.
결제를 지원하지 않으려면 예를 들어 빌드에서 전체 결제 모듈을 제외하면 되고, 삽입된 집합에는 해당 핸들러가 포함되지 않습니다.
이렇게 하면 기능이 지원되는지 여부는 빌드 그래프의 문제입니다.
하지만 이러한 분리는 양날의 검입니다.
직접 참조를 사용하면 모듈을 삭제할 때 빌드가 중단됩니다.
선택적 바인딩 또는 멀티바인딩을 사용하면 모듈을 참조하는 항목이 없으므로 빌드가 통과되고 기능이 자동으로 사라지거나 기본값이 적용됩니다.
그리고 이 지점에 정확히 도달했습니다.
리팩터링으로 인해 기능이 자체 모듈로 이동되었으며 빌드 앱과 워치용 앱에 이 기능이 포함되지 않았습니다.
이로 인해 음성 메시지의 길이가 0초로 표시되었습니다.
이런 일이 발생하지 않도록 하려면 어떻게 해야 할까요?
체크를 다시 넣으려면 테스트가 필요합니다.
이 경우 중요한 인터페이스에 대해 전화 앱과 워치용 앱 간의 멀티바인딩 세트를 비교합니다.
이렇게 하면 올바른 구현이 항상 바인딩됩니다.
이제 트랙 2에서는 아직 삭제할 수 없는 항목을 다룹니다.
모듈을 재사용하면 작업이 줄어들지만, 선언하는 모든 항목이 매니페스트에 병합됩니다.
휴대전화 런처 활동에서 보조 앱 아이콘을 추가하거나 불필요한 콘텐츠 제공자가 앱과 함께 시작됩니다.
매니페스트 병합은 구성요소를 가져올 수 있는 유용한 tools:node="remove" 속성을 제공합니다.
<!-- remove unused Main activity node -->
<activity android:name="com.whatsapp.Main"
tools:node="remove" />
<!-- redirect any calls to a new Activity -->
<activity-alias
android:name="com.whatsapp.Main"
android:targetActivity=".MyNewActivity" />
<provider
android:name="…MigrationContentProvider"
tools:node="remove" />그리고 삭제되면 축소 도구에서 코드도 삭제할 수 있습니다.
활동에 도달해야 하는 항목이 있는 경우 activity-alias를 사용하여 새 클래스로 리디렉션할 수 있습니다.
하지만 이러한 속성은 우선순위가 낮은 매니페스트에만 적용되므로 병합 순서에 따라 삭제 여부가 결정됩니다.
리소스도 같은 방식으로 작동합니다.
Reuse를 사용하면 전화 문자열을 무료로 사용할 수 있습니다.
하지만 일부 앱은 시계에서 할 수 없는 기능을 약속합니다.
모든 채팅의 시작 부분에 있는 암호화 알림의 끝부분을 예로 들어 보겠습니다.
휴대전화에서는 '자세히 알아보기' 링크로 끝납니다.
하지만 워치에는 링크 뒤에 아무것도 없습니다.
따라서 시계의 우선순위가 높은 모듈에서 동일한 키를 다시 선언하고 문자열을 '휴대전화를 사용하여 자세히 알아보기'로 바꿉니다.
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>그런 다음 리소스 병합기가 Google의 리소스를 선택합니다.
또한 실수로 문자열을 재정의하지 않도록 하는 방법도 필요합니다.
따라서 재정의가 허용 목록에 없으면 실패하는 빌드 규칙을 만들었습니다.
이제 알림에 대해 알아보겠습니다.
기본적으로 Wear OS는 휴대전화 알림을 브리징하거나 미러링합니다.
좋습니다. 하지만 휴대전화가 연결되어 있어야만 작동합니다.
그리고 WhatsApp이 독립적으로 작동하기를 원하므로 앱에서 자체 알림을 게시합니다.
하지만 브리징이 켜져 있으면 모든 메시지가 두 번 울립니다.
브리지를 사용 중지하면 중복이 수정됩니다.
정적 매니페스트 플래그가 있지만 사용자가 로그인하기 전에 설치부터 적용됩니다.
따라서 설치하고 로그인하지 않으면 갑자기 모든 알림이 사라지는데, 이는 좋지 않습니다.
따라서 BridgingManager를 사용하여 런타임에 설정합니다.
isRegistered.collect {
when (it) {
// watch posts its own
true -> bridging.setEnabled(false)
// phone bridges again
false -> bridging.setEnabled(true)
}
}
fun setEnabled(bridgingEnabled){
bridgingManager.setConfig(
BridgingConfig.Builder(context,
bridgingEnabled)
.build())
}로그인하면 브리징이 사용 중지됩니다.
로그아웃하면 다시 사용 설정됩니다.
하지만 값을 다시 읽는 API는 없으며 시계에서 값이 손실되는 것을 확인했습니다.
따라서 시계가 충전기에서 분리되거나 앱이 업데이트될 때마다 다시 작성합니다.
마지막으로 로그아웃하는 방법에 대해 알아보겠습니다.
계정에서 시계 연결을 해제하면 메시지도 함께 사라질 것으로 예상됩니다.
따라서 메시지, 키, 설정 등 모든 항목을 삭제합니다.
하지만 어떻게 안전하게 할 수 있을까요?
백그라운드 스레드가 여전히 파일에 쓰는 동안 파일을 삭제하면 SQLite 예외가 발생하거나 새로 다시 생성된 데이터베이스에 쓰기가 발생할 수 있습니다.
프로세스를 중지하지 않고는 프로세스 상태를 지울 수 없습니다.
Android는 데이터를 삭제하고 앱을 중지하는 clearApplicationUserData API를 제공하지만 권한도 재설정합니다.
일부 OEM에서는 알림 브리징이 알림 권한과 연결되어 있으므로 이를 사용하면 휴대전화 미러링도 갑자기 중단됩니다.
따라서 Google에서 직접 프로세스를 중지합니다.
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()DI와 WorkManager가 없는 보조 프로세스에 작은 서비스를 빌드하여 앱의 나머지 부분을 시작하지 않도록 합니다.
로그아웃하면 이 서비스는 앱의 다른 모든 프로세스를 종료하고, 경합 상태가 없는 경우에만 모든 데이터를 삭제합니다.
clearApplicationUserData를 사용하지 않으므로 권한이 유지되고 알림 브리징을 사용 설정할 수 있으므로 휴대전화 알림이 다시 표시됩니다.
여기까지가 첫 번째 부분입니다. 이제 수바그에게 넘겨 UI 업데이트에 관해 이야기하겠습니다.
마틴님, 감사합니다.
공유 인프라가 마련된 상태에서 Google은 WhatsApp이 손목에서 정말 편안하게 느껴지도록 하는 데 집중했습니다.
이 여정의 첫 번째 단계는 Material 3로의 전환이었지만, 먼저 그 이유를 알아보겠습니다.
모든 픽셀이 중요한 원형 디스플레이에서 Material 3는 손목에 맞게 WhatsApp을 현대화할 수 있는 적절한 구성요소와 디자인 기반을 제공했습니다.
시각적 요소를 살펴보면 표면적으로는 UI 업데이트처럼 보일 수 있습니다.
Material 2에서 3로 전환하는 중이었는데, 하루아침에 이루어질 수 있는 일이 아니었습니다.
이전 코드와 새 코드가 스위치 뒤에 나란히 존재해야 했습니다.
그 결과 Figma와 시각적으로 일치하는 화면에서 여전히 일부 비정상적인 동작이 나타났습니다.
예를 들어 크라운 스크롤, 오른쪽으로 스와이프하여 뒤로 가기 등 가장 기본적인 동작 중 일부가 작동하지 않았습니다.
즉, 디자인 시스템 이전은 시각적 이전일 뿐만 아니라 동작 이전이기도 합니다.
동작에 대해 이야기하자면, 가장자리 근처의 행을 보면 더 좁고 곡선에 들어가 있습니다.
이것이 TransformingLazyColumn입니다.
원형 베젤에 의해 잘리지 않도록 행이 변환됩니다.
동적 화면은 상황을 까다롭게 만들 수 있습니다.
새 메시지가 도착하고, 행이 이동하고, 다양한 모양의 풍선이 나타납니다.
올바른 접근 방식이 무엇인지 살펴보겠습니다.
왼쪽을 보면 말풍선이 너무 급격하게 줄어들고 있습니다.
각 메시지는 내부적으로 카드 안에 래핑된 말풍선입니다.
상위 카드에 변환을 적용하도록 지시했지만 동일한 변환이 하위 말풍선에도 전달되었습니다.
그 결과 하위 말풍선은 의도한 변환의 두 배를 받게 됩니다.
이 문제는 상위 카드에서 한 수준으로 변환을 적용하여 해결했습니다.
이것 역시 핵심 원칙입니다.
항목 하나에 변환 하나를 적용했습니다.
또 다른 문제가 있습니다. 화면 중앙에 있는 풍선이 원래 크기로 표시되어야 하는데 작고 흐리게 표시됩니다.
크기는 화면에서의 위치에 따라 달라집니다.
초기 버전에서는 Compose의 remember 함수를 사용하여 크기 맞춤을 저장했지만 이전 위치도 저장되었습니다.
이제 새 메시지가 도착하여 이전 버블이 위로 이동하면 변환은 여전히 이전 위치를 기반으로 합니다.
이로 인해 버그가 발생합니다.
이 문제를 해결하기 위해 remember 호출을 사용하여 크기를 저장하는 것을 중지하고 호출을 삭제했습니다.
이 또한 배움입니다.
행이 이동하면 변환도 함께 이동해야 합니다.
여기에서 코드의 두 수정사항을 모두 확인할 수 있습니다.
// FIX #1: ONE OWNER
CompositionLocalProvider(
LocalSurfaceTransformation provides null
) {
M3Card(transformation = t) {
SharedChatEntry(...)
}
}
// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
CompositionLocalProvider(
LocalSurfaceTransformation provides t
) { SharedChatEntry(...) }
}
// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
SurfaceTransformation(spec)
}왼쪽 상단에서는 변환이 두 수준에서 적용되고 있고 오른쪽 상단에서는 한 수준(M3 카드)에서만 적용되고 있습니다.
왼쪽 하단에서는 remember 호출을 사용하여 변환을 캐시하지만 위치도 캐시합니다.
오른쪽 하단에서는 매번 풍선의 최신 위치를 고려하여 변환을 계산합니다.
보시다시피 모든 화면에서 일관된 환경이 제공되며, 주요 콘텐츠는 중앙에 유지되고 행은 가장자리 근처로 자연스럽게 이동합니다.
다음으로 알림에 대해 알아보겠습니다.
이제 손목이 진동하고 시계를 확인하면 메시지가 표시된다고 상상해 보세요.
하지만 잠시 후 손목에서 다시 진동이 울립니다.
여러 알림을 받지만 확인할 때마다 동일한 내용이 표시됩니다.
정말 짜증 나네요.
이 문제를 해결하기 위해 두 가지 질문을 던졌습니다.
어떤 이벤트가 사용자에게 알림을 보내야 하나요?
어떤 이벤트가 알림을 자동으로 업데이트해야 하나요?
이 버그는 백그라운드에서 프로필 사진 업데이트, 미디어 다운로드, 영수증이 알림을 자동으로 업데이트하는 대신 사용자에게 알림을 표시하기 때문에 발생했습니다.
이제 새 메시지가 도착하면 손목이 정확히 한 번 진동하며, 알림이 필요하지 않은 이벤트는 알림을 조용히 새로고침합니다.
이 또한 교훈입니다.
알림이 핵심 사용자 여정 중 하나인 경우 각 알림이 사용자의 관심을 끌 만한 가치가 있는지 확인해야 합니다.
알림에 관한 주제입니다.
이전에는 WhatsApp Wear OS 알림을 통해 두 가지 작업을 할 수 있었습니다.
메시지를 읽거나 앱을 열 수 있습니다.
알림에 직접 답장하고 싶어도 이전에는 할 수 없었습니다.
따라서 RemoteInput API와 함께 Android의 알림 작업을 사용하여 이 격차를 해소했습니다.
이제 사용자는 입력하거나, 말하거나, 빠른 답장을 탭할 수 있습니다.
하지만 전송 버튼을 누르는 것이 끝이 아닙니다.
메시지가 전송되면 채팅이 읽음으로 표시되고 알림이 지워집니다.
메시지 전송에 실패하면 한 번의 탭으로 다시 시도할 수 있습니다.
이 기능을 개발하면서 얻은 또 다른 중요한 교훈은 알림 작업을 완전한 엔드 투 엔드 워크플로로 취급해야 한다는 것입니다.
다음은 타일 및 위젯입니다.
보시다시피 카드는 시계 화면에서 스와이프하여 액세스할 수 있는 전체 화면입니다.
반면 위젯은 여러 개의 작은 카드로, 스크롤 가능한 화면 하나에 여러 개를 표시할 수 있습니다.
WhatsApp 타일과 위젯은 모두 동일한 기본 데이터를 기반으로 합니다.
이것도 반전입니다.
이 UI는 앱에서 그리는 것처럼 보이지만 실제로는 그렇지 않습니다.
UI와 데이터만 설명하면 시스템이 이를 그립니다.
또한 아이콘을 탭해도 앱의 코드가 실행되지 않습니다.
화면을 열도록 요청하는 것만 가능합니다.
이것으로 강의를 마치겠습니다.
시스템이 UI를 그리는 경우 이는 제품 및 테스트 측정항목의 일부입니다.
프랭크와 유리가 위젯에 관한 강연에서 더 자세히 설명할 예정입니다.
마지막 슬라이드로 넘어가겠습니다. 한 걸음 물러서서 자문해 보겠습니다. 결국 우리는 더 많은 사용자가 우리 앱을 사용하기를 원합니다.
이제 개발자로서 어느 날 대시보드에 로그인했는데 활성 사용자 수가 급격히 감소한 것을 발견했다고 가정해 보겠습니다.
우선, 매우 우려스러운 상황입니다.
그런 다음 디버깅을 시작할 수 있습니다.
비정상 종료가 발생했거나 잘못된 출시가 진행 중이라고 느낄 수 있습니다.
하지만 저희는 이 중 어느 것도 해당하지 않았습니다.
모든 WhatsApp 앱 버전에는 90일의 기본 만료 기간이 있는 것으로 확인되었습니다.
따라서 사용자가 이전 빌드에 머물러 있는 동안 새 빌드가 시계에 도달하는 데 시간이 걸린다고 가정해 보겠습니다.
따라서 사용자가 앱을 종료하지 않았지만 앱이 작동을 중지합니다.
그래서 앱 업데이트를 제품의 일부로 만들었습니다.
화면에 표시된 것처럼 앱 만료 15일 전에 이 메시지가 화면에 표시됩니다.
그런 다음 7일, 5일, 3일, 2일, 1일이 남았을 때 다시 표시됩니다.
사용자는 이를 닫거나 Play 스토어로 이동하여 앱을 업데이트할 수 있습니다.
이렇게 하면 사용자에게 앱 만료가 다가오고 있음을 여러 번 알리고 앱을 업데이트할 기회를 여러 번 제공할 수 있습니다.
이것이 마지막 학습입니다.
업데이트를 100% 출시했다고 해서 개발자의 작업이 완료되는 것은 아닙니다.
사용자가 실제로 업데이트를 받아야만 완료됩니다.
이것으로 세션을 마치겠습니다.
대화를 계속 이어가고 싶다면 실무자와의 만남 시간에 언제든지 인사해 주세요.
다음으로 프랭크와 유리가 Wear 위젯에 관해 이야기하겠습니다.
감사합니다.
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}@Module
interface CoreModule {
@Multibinds
fun handlers(): Set<MessageHandler>
}
@Module
interface ReactionsModule {
@Binds @IntoSet
fun reactions(impl: ReactionHandler):
MessageHandler
}
class MessageDispatcher @Inject constructor(
private val handlers: Set<MessageHandler>,
)<!-- remove unused Main activity node -->
<activity android:name="com.whatsapp.Main"
tools:node="remove" />
<!-- redirect any calls to a new Activity -->
<activity-alias
android:name="com.whatsapp.Main"
android:targetActivity=".MyNewActivity" />
<provider
android:name="…MigrationContentProvider"
tools:node="remove" /><!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>isRegistered.collect {
when (it) {
// watch posts its own
true -> bridging.setEnabled(false)
// phone bridges again
false -> bridging.setEnabled(true)
}
}
fun setEnabled(bridgingEnabled){
bridgingManager.setConfig(
BridgingConfig.Builder(context,
bridgingEnabled)
.build())
}clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()// FIX #1: ONE OWNER
CompositionLocalProvider(
LocalSurfaceTransformation provides null
) {
M3Card(transformation = t) {
SharedChatEntry(...)
}
}
// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
CompositionLocalProvider(
LocalSurfaceTransformation provides t
) { SharedChatEntry(...) }
}
// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
SurfaceTransformation(spec)
}