[Android] View #1 Android UI
최근에 ‘만보기’ 앱을 만들면서 기간(년/월/주/일)에 따라 걸음수를 차트형태로 보여주는 커스텀뷰를 만들어야 할 필요가 있었다.
하지만 이전프로젝트 에서 부터 커스텀뷰를 다뤄보지 않아 가장 기본이면서 핵심요소인 화면이 어떻게 만들어지고 랜더링되고 사라지는순간 까지의 라이프사이클에 대해 깊게 파악하지 못하고 있었다는 생각이 들었다.
그래서 여러 레퍼런스와 google Developers 공식 유튜브를 보면서 안드로이드의 뷰와 컴포즈에서의 UI의 랜더링 과정을 공부했던 내용을 정리해보고자 한다.
View
먼저 가장 기초적이면서 기본적인 View는 TextView, ImageView 와 같은 위젯들과 LinearLayout, ConstraintLayout같은 ViewGroup으로 구분된다.
이모든 것들은 최상위 클래스 View를 상속받고 있으며, ViewGroup을 구현한 것들은 또다른말로 Layout 이라고 부른다.
보통 ViewGroup 이라는 Layout에 위젯들을 구성시켜 화면을 만들며, ViewGroup에는 ViewGroup과 View 모두 포함될수 있다.
이미 알고있듯이 View는 inflate 과정을 거쳐 메모리상에 생성되고, 이 뷰객체에 접근하여 우리가 원하는 형태로 자바코드상에서 제어할 수 있다.
그것이 가능하게 만드는 함수가 ComponentActivity.setContentView() 메소드 이다.
setContentView() 메소드를 통해 rootViewImpl을 전달받으면, tree 형태로 저장되어 있어 top-down 방식으로 순회하여 화면에 rendering 하게 된다.
한번 내부를 살펴보자.
@Override
public void setContentView(@LayoutRes int layoutResID) {
initViewTreeOwners();
mReportFullyDrawnExecutor.viewCreated(getWindow().getDecorView());
super.setContentView(layoutResID);
}
@Override
public void setContentView(@SuppressLint({"UnknownNullness", "MissingNullability"}) View view) {
initViewTreeOwners();
mReportFullyDrawnExecutor.viewCreated(getWindow().getDecorView());
super.setContentView(view);
}
@Override
public void setContentView(@SuppressLint({"UnknownNullness", "MissingNullability"}) View view,
@SuppressLint({"UnknownNullness", "MissingNullability"})
ViewGroup.LayoutParams params) {
initViewTreeOwners();
mReportFullyDrawnExecutor.viewCreated(getWindow().getDecorView());
super.setContentView(view, params);
}
내부 구현체는 위와 같은데, componentActivity는 Activity를 상속받은 클래스이므로 super클래스를 따라가 보면
public void setContentView(View view) {
getWindow().setContentView(view);
initWindowDecorActionBar();
}
window라는 녀석의 setContentView() 메소드를 호출하고 있다. 그렇다면 window는 대체 뭘까?
View hierarchy
window는 화면을 구성하기 위한 ‘창’ 으로써 도화지의 역할을 한다. 그림을 그리기 위해 도화지가 필요하듯이 window 가 반드시 필요하다.
우리가 만든 View 들은 모두 window 에 배치되어 화면상에 구성된다. 그렇다면 뷰계층이 어떻게 구성되는지 한번 살펴봐야 한다.
뷰 계층은 Application > Activity > window > decorView > ViewGroup > View 의 형태로 구성된다.
여기서 window라는 도화지에는 반드시 surface가 하나 존재하고, 이는 View를 랜더링하기 위한 픽셀 개념이며 이안에서 paint로 어떻게 그릴건지를 설정하고 canvas로 그려서 뷰가 화면에 보여지게 된다.
조금더 깊이 들어가보자. 그렇다면 위에서 말한 setContentView() 메소드가 있는 Window가 어디서 설정되는지 부터 보겠다.
ActivityThread
앱이 실행되면, app_process.cpp 코드에 의해 zygote.java 클래스가 로딩되고 VM이 구동된다. 여기서 zygoteInit.java 클래스가 로딩되면서 앱의 프로세스가 생성된다.
앱의 프로세스가 생성되면 JVM위에 클래스로더에 의해 .class 파일들을 로드하여 RuntimeDataArea에 적재한다. JVM에 적재후 가장먼저 해야하는 일은 main()함수를 실행하는 것이다.
이 main()함수는 내부적으로 ActivityThread 클래스의 main()함수를 실행하게되고, 여기서 MainLooper와 H 라는 Handler클래스 인스턴스를 초기화한다.
이것이 바로 안드로이드의 UI Thread 인 Main Thread의 역할을 담당하게 되고, main looper의 loop()를 동작시킴에 따라 message들을 전달받고 내부 동작들을 수행함에 따라 handleLaunchActivity() 를 수행시켜 그안에서 performLaunchActivity() 메소드를 실행시킨다.
뿐만아니라 ActivityThread에서는 ActivityManager의 요청에 따라 작업을 처리하는 역할을 수행한다.
performLaunchActivity() 메소드 에서 activity.attach() 라는 메소드를 호출시킴으로써 만들어진 activity에 필요한 Instrumentation, application, intent , configuration등 수많은 리소스들이 초기화되면서,
내가 알고싶었던 window가 초기화되고 그안에서 내부적으로 decorView가 만들어 진다.
setContentView는 이 decorView에 개발자가 만든 .xml 파일의 layoutResoure를 inflate하여 만들어진 뷰객체를 연결하는 작업을 수행하게 되는 것이다.
그 내부구조를 한번 살펴보자.
Activity.attach()
final void attach(Context context, ActivityThread aThread,
Instrumentation instr, IBinder token, int ident,
Application application, Intent intent, ActivityInfo info,
CharSequence title, Activity parent, String id,
NonConfigurationInstances lastNonConfigurationInstances,
Configuration config, String referrer, IVoiceInteractor voiceInteractor,
Window window, ActivityConfigCallback activityConfigCallback, IBinder assistToken,
IBinder shareableActivityToken) {
attachBaseContext(context);
mFragments.attachHost(null /*parent*/);
mActivityInfo = info;
mWindow = new PhoneWindow(this, window, activityConfigCallback);
mWindow.setWindowControllerCallback(mWindowControllerCallback);
mWindow.setCallback(this);
mWindow.setOnWindowDismissedCallback(this);
mWindow.getLayoutInflater().setPrivateFactory(this);
if (info.softInputMode != WindowManager.LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED) {
mWindow.setSoftInputMode(info.softInputMode);
}
if (info.uiOptions != 0) {
mWindow.setUiOptions(info.uiOptions);
}
'''
이하 생략
}
Activity.attach() 메소드에서는 엄청많은 파라미터를 받아 init해주고 있는데 여기서 눈여겨 봤던 부분은 mWindow 라는 Activity의 지역변수에 PhoneWindow() 라는 클래스 생성자로 초기화 해주고 있는 부분이었다.
따라서, PhoneWindow 클래스 생성자로 만들어진 mWindow 인스턴스에 있는 setContentView() 를 호출하는 것이니 PhoneWindow 클래스 내부 setContentView() 메소드를 볼 필요가 있다.
PhoneWindow()는 java파일로 internal 클래스여서 Android Code Search 에서 확인할 수 있었다.
@Override
public void setContentView(int layoutResID) {
if (mContentParent == null) {
installDecor();
} else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
mContentParent.removeAllViews();
}
if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
getContext());
transitionTo(newScene);
} else {
mLayoutInflater.inflate(layoutResID, mContentParent);
}
mContentParent.requestApplyInsets();
final Callback cb = getCallback();
if (cb != null && !isDestroyed()) {
cb.onContentChanged();
}
mContentParentExplicitlySet = true;
}
여기서 중요한 부분은 mContentParent와 installDecor() 부분이다.
살펴보면 mContentParent 가 null이면 installDecor() 메소드를 통해 decorView를 생성한다.
hasFeature의 내부구조는
public boolean hasFeature(int feature) {
return (getFeatures() & (1 << feature)) != 0;
}
getFeatures()는 requestFeature()로 만들어진 mFeatures를 말하고 여기에 특정 feature가 있는지를 확인하는 메소드다.
private void installDecor() {
mForceDecorInstall = false;
if (mDecor == null) {
mDecor = generateDecor(-1);
mDecor.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
mDecor.setIsRootNamespace(true);
if (!mInvalidatePanelMenuPosted && mInvalidatePanelMenuFeatures != 0) {
mDecor.postOnAnimation(mInvalidatePanelMenuRunnable);
}
} else {
mDecor.setWindow(this);
}
if (mContentParent == null) {
mContentParent = generateLayout(mDecor);
'''
중략
'''
}
int layoutResource;
int features = getLocalFeatures();
if ((features & (1 << FEATURE_SWIPE_TO_DISMISS)) != 0) {
layoutResource = R.layout.screen_swipe_dismiss;
setCloseOnSwipeEnabled(true);
} else if ((features & ((1 << FEATURE_LEFT_ICON) | (1 << FEATURE_RIGHT_ICON))) != 0) {
if (mIsFloating) {
TypedValue res = new TypedValue();
getContext().getTheme().resolveAttribute(
R.attr.dialogTitleIconsDecorLayout, res, true);
layoutResource = res.resourceId;
} else {
layoutResource = R.layout.screen_title_icons;
}
removeFeature(FEATURE_ACTION_BAR);
}
'''
중략
'''
mDecor.startChanging();
mDecor.onResourcesLoaded(mLayoutInflater, layoutResource);
ViewGroup contentParent = (ViewGroup)findViewById(ID_ANDROID_CONTENT);
if (contentParent == null) {
throw new RuntimeException("Window couldn't find content container view");
}
'''
중략
'''
return contentParent;
}
installDecor() 내부를 보면 이곳에서 generateDecor()로 decorView를 생성하고 난뒤, mContentParent에 generateLayout()으로 초기화 해주고 있다.
generateDecor()는 간단하게 applicationContext가 있으면 이것으로 아니면 가지고있는 context로 decorView를 생성하고 있다.
generateLayout()은 굉장히 코드가 긴데, 요약하자면 styles.xml 에 정의한 대로 속성값들을 비교하여 requestFeature() 을 통해 feature들을 mFeatures에 등록하는 과정을 수행한 뒤
안드로이드 internal framework 에서 mFeatures에 맞는 layoutResoure을 찾아내서 onResourcesLoaded()메소드로 mDecor에 로딩한다.
그리고 마지막으로 decorView에 만들어진 id값이 content인 녀석을 contentParent에 할당하고 이를 리턴하면서 메소드가 종료된다.
요약하자면,
- 뷰 계층구조는 Application > Activity > window > decorView > ViewGroup or View
- 내부적으로 styles.xml에 개발자가 정의한대로 layoutResource를 만들고 이것이 activity의 window의 decorView에 붙어있으며
- setContentView() 메소드를 통해 만들어진 .xml layoutResoure를 파라미터로 넘겨주는 행위는 이곳의 mContentParent(id: content)인 ViewGroup에 layoutResource가 inflate되어 연결되어지는 작업이다.
흐름요약은,
zygote 클래스가 app_process 위에서 실행되면, VM 을 생성후 실행하고 여기서 zygoteInit 클래스를 로딩후 실행 -> process생성 ->
JVM위에 App 클래스들이 로딩되고 -> ActivityThread의 main()함수를 실행 -> mainLooper 와 H(Handler) 를 초기화 -> launch 메세지를 H에서 수신하여 -> handleLaunchActivity() -> performLaunchActivity() ->
activity.attach() -> window = PhoneWindow() -> mWindow.setContentView() -> installDecor() -> generateDecor()-> generateLayout() -> decorView의 mContentParent(id: content) 에 우리가만든 .xml 연결
개발자가 만든 layoutResource 들이 inflate되어 activity에 연결되어지는 과정을 순차적으로 알아보았다. 만들어진 뷰가 랜더링되는 lifecycle을 한번 살펴보자.
View Lifecycle
View의 Lifecycle은 다음 9가지로 과정으로 구분된다.
해당 과정들을 하나씩 살펴보자.
생성자 호출
View(Context context)
View(Context context, @Nullable AttributeSet attrs)
View(Context context, @Nullable AttributeSet attrs, int defStyleAttr)
View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes)
가장먼저 View클래스를 상속받는 클래스를 만들어 줌으로써 커스텀뷰 클래스를 만들 수 있다.
View 생성의 시작점은 생성자를 호출하는 것인데, AttributeSet의 경우 뷰의 attr 태그로 작성되는 크기, 색상 등이 정의된 속성이다.
styles.xml 과 theme.xml 에 정의된 형태로 뷰를 생성하려면 첫번째 생성자를 사용하고, 커스텀뷰를 위해 특정 속성들이 포함되어야 한다면 아래생성자 들을 사용한다.
onAttachToWindow()
만들어진 View가 위에서 설명한대로 decorView의 mContentParent에 연결되고 나서 호출되는 시점이다.
이콜백을 받은 시점부터 View의 리소스들을 할당하고 리스너를 등록하는 등 뷰객체에 접근하고 사용할수 있게된다.
Measure Pass
뷰를 랜더링하기 위해 가장먼저 크기를 측정해야 한다.
Measure Pass에서는 top-down 으로 view tree를 후위순회 하면서 자식의 크기를 지정한뒤, 부모의 크기를 지정하게 된다.
measure() 를 호출하면 이메소드에서 onMeasure()를 호출하게 되는데, root view부터 measure() 호출을 하게되면 자식 view의 measure()를 호출하고 leaf node라면 onMeasure()를 호출하여 그자신의 크기를 결정시킨다.
이때 파라미터로 measureSpec 을 전달해야 한다. measureSpec을 통해 부모는 자식의 뷰의 크기에 제약을 걸수 있다.
MeasureSpec
- UnSpecified : 부모가 자식뷰의 크기를 명시적으로 제한하지 않아, 원하는 크기를 가질수 있다.
- Exactly : 자식뷰의 크기를 특정 크기만큼 명시적으로 제한한다.
- AtMost : 자식뷰의 최대 크기를 지정한다. 이때 자식뷰는 최대크기 범위 안에서 본인의 크기를 가질 수 있다.
또한 LayoutParams 값으로 자식뷰가 크기를 명시적으로 부모뷰에게 알릴 수 있다.
LayoutParams
- 수치값 : 특정 수치값을 직접 넘겨 크기를 명시할 수 있다.
- Wrap_content : 내용물(content) 의 크기에 패딩 만큼을 포함한 크기를 가진다.
- Match_parent: 부모 크기만큼을 가진다. 이때 패딩의 크기는 가지지 않는다.
measure() 의 경우 한번이상 호출 될 수 있는데, MeasureSpec.UnSpecified 일 때 자식의 크기의 합이 너무 크거나 작을 때 Exactly값으로 다시한번 호출할수 있다.
onMeasure()에서는 값을 반환하지 않으며, setMeasuredDimension()메소드를 호출하지 않으면 예외가 발생한다.
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
위는 View 클래스에 정의된 onMeasure() 메소드 구현체 이다.
커스텀뷰를 생성하기 위해 onMeasure()를 오버라이딩 한다면, super.onMeasure() 를 호출시키거나 setMeasuredDimension()을 명시적으로 호출하면서 width와 height를 결정시켜야 한다.
measure() 이후에는 getMeasuredWidth() 와 getMeasuredHeight()로 결정된 크기를 반환받을 수 있는데, 이 크기는 getWidth()와 getHeight() 와는 다르다.
getWidth(), getHeight()는 뷰가 그려지고난 뒤 인 onDraw() 이후에 완전히 결정된 뷰의 크기를 받는 메소드이니 수명주기에 따라 값이 다를 수 있다.
Layout Pass
measure pass가 뷰의 크기를 계산하여 결정한다면, layout pass는 뷰의 위치를 지정하여 배치하는 단계이다.
layout()을 호출하면 view hierarcy에서 마찬가지로 top-down 으로 순회하면서 부모부터 자식까지 후위순회 를 통해 배치하게 된다.
Drawing Pass
측정된 크기와 위치에 맞게 canvas와 paint를 이용하여 실제로 뷰를 그리는 단계이다. 뷰를 그리기 위해서 CPU에서 계산된 뷰에 대한 정보들을 disPlayList에 담아 OpenGL을 통해 GPU 메모리로 전달하게 된다.
중요한점은 draw pass에서는 view hierarcy에서 top-down 으로 전위순회 한다는 점이다.
ViewGroup인 layout이 먼저 그려지고, 그안에 textview 나 imageview 같은 view들이 위치해야지 그반대 순서로 그려진다면 textview가 viewgroup에 가려져서 안보이게 되기 때문에 전위순회 되는 것이다.
dispatchToDraw() 메소드는 자식뷰를 그리기 전에 호출되는 메소드이다. 자식뷰를 다시 그려야할 때 호출한다.
이후 draw() 메소드에서 onDraw()를 호출하여 canvas 객체가 전달되고, 이것으로 뷰를 그리기 위한 작업을 수행한다.
또한, onDraw()는 여러번 호출될 수 있고 빠르게 수행되어야 하기 때문에 이곳에서 인스턴스를 생성하는 작업을 하면 안된다.
View의 변화
만약, 뷰의 색상이나 텍스트 같이 뷰의 위치나 크기에는 변화가 없이 다시그려야 하는 상황이 발생하면 invalidate() 메소드를 호출하며
뷰의 위치와 크기가 모두 바뀌어 새로 뷰를 처음부터 그려야 한다면 requestLayout()을 호출한다.
행위에서 예측가능하듯이 invalidate()는 dispatchToDraw() ~ onDraw() 까지 재수행되며, requestLayout은 onMeasure() ~ onDraw() 까지 재수행 된다.
끝으로
다음파트에서는 컴포즈의 Layout이 어떻게 만들어지고 activity/fragment에 부착되고, 제거되는지에 대한 라이프사이클을 다루고, 커스텀뷰를 만들면서 공부한 내용을 정리해보겠다.
댓글남기기