UI 자동화에서 "이 버튼이 그 버튼인가"를 판단하는 방법은 여러 가지입니다. 저는 그중 두 개를 연달아 골랐고, 둘 다 틀렸습니다.
당신의 봇이 화면을 식별하는 기준은, 식별하려는 대상이 절대 변할 수 없는 축입니까? 아니면 그냥 지금 화면에서 우연히 참인 축입니까?
봇이 하는 일
폰을 adb로 직접 조종하는 봇이 있습니다. 계정을 바꾸려면 먼저 홈 화면으로 돌아가야 하는데, 세션은 늘 엉뚱한 화면에서 끝나 있습니다. 그래서 _unwind라는 함수가 있습니다 — 모달이 떠 있으면 닫고, 아니면 뒤로 가고, 홈 탭이 보일 때까지 최대 8회 반복.
for _ in range(8):
if find(dump(), is_profile_tab, H):
return True
# 모달이면 닫기 버튼, 아니면 back
...문제는 "모달이면"을 어떻게 아느냐입니다.
1차 시도: 라벨
처음엔 라벨로 갈랐습니다. 닫기 버튼은 Not now, Close, Skip, Dismiss 중 하나일 테니까요.
DISMISS = ("Not now", "Close", "Skip", "Dismiss", "Cancel", "Maybe later")이건 빨리 무너졌습니다. 목록의 각 행에도 content-desc="Dismiss"가 달려 있었습니다. 추천 사용자 목록에서 "이 추천 숨기기" 버튼이 그 라벨을 씁니다.
그래서 봇은 매번 행 버튼을 눌렀습니다. 추천 하나가 사라지고, 화면은 그대로고, 8회를 다 태우고 실패합니다. 그리고 그 뒤에 앱 리셋이 붙어 있어서 그것마저 어긋나면 계정 전환 자체가 실패로 끝납니다. 실패 76건 중 30건이 이 경로였습니다.
2차 시도: 개수
라벨이 못 미더우니 다른 축을 찾았습니다. 그때 제 논리는 이랬습니다.
모달의 닫기 버튼은 화면에 하나뿐이다. 여러 개면 그건 모달이 아니라 목록 행의 버튼이다.
실측 근거도 있었습니다. 팔로워 목록에서 4개, 필터 목록에서 2개, 추천 화면에서 8개가 잡혔습니다. 전부 2개 이상이었습니다.
cands = find_all(root, lambda t, d: (t in DISMISS or d in DISMISS))
if len(cands) == 1:
tap(*cands[0]) # 모달로 판단
else:
back()주석에는 이렇게까지 써뒀습니다. "개수는 개명·다국어에 안 흔들리는 축이라 라벨/rid 목록 대신 이걸 쓴다."
절반만 맞는 말이었습니다. 개수는 개명에는 안 흔들립니다. 대신 스크롤 위치에 흔들립니다.
여기서 잠깐 — 당신이라면?
목록 화면에서 Dismiss 버튼이 딱 1개만 보이는 경우가 있을까요?
저는 없다고 봤습니다. 목록에는 행이 여러 개니까요. 3분만 더 생각했으면 답이 나왔을 겁니다.
3차: 실패한 화면을 실제로 열어봤습니다
며칠 뒤 계정 전환 실패가 또 한 건 났습니다. 이번에는 다행히 실패 순간의 화면 덤프가 저장돼 있었습니다 — 직전 수정 때 "무슨 화면에서 못 빠져나왔는지 아무도 모른다"는 게 걸려서 계측을 넣어뒀거든요. 그게 유일하게 이 글을 가능하게 했습니다.
덤프를 열어봤습니다.
action_bar_title 'Discover people'
row_header_textview 'Suggested for you'
... (추천 행 6개) ...
row_recommended_hide_icon_button [1001,2102][1035,2136] desc='Dismiss'추천 목록 화면인데 Dismiss 버튼이 정확히 1개입니다. 왜냐면 대부분의 행은 이미 Requested/Following 상태로 바뀌어서 숨기기 버튼이 안 뜨고, 화면 맨 아래 "More suggestions" 섹션의 새 행 하나에만 남아 있었기 때문입니다.
개수 1개. 제 코드는 이걸 모달로 판단하고 눌렀습니다. 8회 전부. 그리고 앱 리셋 후 다시 8회.
진짜 불변축
세 번째로 고를 축은 실패하면 안 됐습니다. 그래서 이번엔 추측하지 않고, 저장된 실패 화면 15장 전부에서 Dismiss 후보의 조상 체인을 뽑았습니다.
follow_list_container < LinearLayout < ListView#list < ...
LinearLayout < recommended_user_row_content_identifier < RecyclerView#recycler_view < ...15장 중 15장. 오탭된 버튼은 예외 없이 ListView 또는 RecyclerView의 자손이었습니다.
그리고 이건 우연이 아니라 구조입니다. 목록 행 버튼은 스크롤 컨테이너 안에 있어야 스크롤이 되고, 모달 닫기 버튼은 다이얼로그 안에 있지 목록 안에 있지 않습니다. 라벨처럼 번역되지도, 개수처럼 스크롤에 흔들리지도 않습니다.
LIST_CONTAINERS = ("ListView", "RecyclerView")
def _modal_dismissers(root):
parent = {c: p for p in root.iter() for c in p}
out = []
for n in root.iter("node"):
if n.get("clickable") != "true":
continue
t, d = label(n)
if not (t in DISMISS or d in DISMISS):
continue
cur, in_list = n, False
while cur in parent:
cur = parent[cur]
if (cur.get("class") or "").rsplit(".", 1)[-1] in LIST_CONTAINERS:
in_list = True
break
if not in_list:
out.append(center(n.get("bounds")))
return out검증은 저장해둔 15장으로
새 로직을 15장 전부에 다시 돌렸습니다.
실측 15장 중 여전히 행버튼 탭: 0오탭 15/15 → 0/15. 그리고 반대 방향 회귀도 막아야 합니다. 목록 안 행 버튼을 뺐더니 진짜 모달까지 안 눌리면 그건 과잉필터니까요. 목록 밖 Not now와 목록 안 Dismiss가 같이 있는 화면을 합성해서, 모달만 눌리는지 테스트로 고정했습니다.
한 가지 더 알게 된 게 있습니다. 행 버튼을 누르는 건 헛수고가 아니라 부작용입니다. 모달이 안 닫히는 정도가 아니라, 추천을 실제로 한 건 지웁니다. 8회 반복이면 8건입니다.
자가진단 3개
UI를 프로그램으로 만지는 코드가 있다면 확인해보세요.
- 당신의 앵커는 무엇을 축으로 씁니까? 텍스트라면 다국어·개명에 죽고, 좌표라면 해상도에 죽고, 개수라면 스크롤에 죽습니다. 구조(조상·소속)가 가장 오래 버팁니다.
- 실패한 순간의 화면을 저장하고 있습니까? 저장이 없으면 "왜 실패했나"는 영원히 추측입니다. 이 버그는 덤프 15장이 있어서 3차 만에 끝났지, 없었으면 4차·5차도 갔을 겁니다.
- 틀린 탭이 부작용을 냅니까? 아무 일도 안 일어나는 오탭과 데이터를 지우는 오탭은 다른 등급의 버그입니다.
솔직한 부분
주석에 "이건 불변축이다"라고 자신 있게 적어놓은 게 두 번 다 틀렸습니다. 라벨도, 개수도요.
차이는 이번엔 실측 15장으로 확인했다는 것뿐입니다. ListView 소속이 진짜 불변인지는 저도 모릅니다 — 목록을 품은 바텀시트가 나오면 또 틀릴 수 있습니다. 다만 그때는 back()으로 떨어지고, back()은 대부분의 다이얼로그를 닫습니다. 틀렸을 때 안전한 쪽으로 떨어지는지를 축 선택만큼 신경 썼습니다.
이 실패 1건이 어느 판정에 들어가고 어느 판정에서 빠지는지는 판정을 이틀 미뤘더니 내 기준이 저절로 세졌습니다에 적었습니다.
지금 당신 봇의 앵커 한 줄을 열어보세요. 그 축이 무엇에 흔들릴 수 있는지, 한 문장으로 답할 수 있습니까?