Android Toast 소스 분석
이번 주에 항주에 가서 백아 훈련에 참가하여 전설 속의 소인 다륭대신을 만났다.도롱 대신에게서 기술자가 되는 순수하고 단순함을 보았다.도롱대신을 만나는 것 외에 이번 훈련은 많은 수확을 거두지 못했고 오히려 훈련 과정에서 많은 제품의 버그를 만났기 때문에 원격 근무는 죽을 지경이었다.토스트와 관련된 문제를 정리하고 토스트의 원본 실현을 깊이 있게 배우는 것부터 시작한다.
Toast 소스 구현
Toast 입구
Toast 프롬프트를 응용프로그램에서 사용할 때 일반적으로 다음과 같은 간단한 코드 호출 행이 사용됩니다.
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
makeText는 바로Toast의 입구이다. 우리는makeText의 원본 코드로Toast의 실현을 깊이 이해한다.소스는 다음과 같습니다(frameworks/base/core/java/android/widget/Toast.java). public static Toast makeText(Context context, CharSequence text, int duration) {
Toast result = new Toast(context);
LayoutInflater inflate = (LayoutInflater)
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
tv.setText(text);
result.mNextView = v;
result.mDuration = duration;
return result;
}
makeText의 원본 코드에서 Toast의 레이아웃 파일은transientnotification.xml,frameworks/base/core/res/res/layout/transientnotification.xml: <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:attr/toastFrameBackground">
<TextView
android:id="@android:id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_gravity="center_horizontal"
android:textAppearance="@style/TextAppearance.Toast"
android:textColor="@color/bright_foreground_dark"
android:shadowColor="#BB000000"
android:shadowRadius="2.75"
/>
</LinearLayout>
시스템 토스트의 레이아웃 파일은 매우 간단하다. 바로 수직 레이아웃의LinearLayout에 TextView를 설치한 것이다.다음에 우리는 계속해서 show() 방법을 따라 레이아웃이 형성된 후의 전시 코드 실현을 연구한다. public void show() {
if (mNextView == null) {
throw new RuntimeException("setView must have been called");
}
INotificationManager service = getService();
String pkg = mContext.getPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
try {
service.enqueueToast(pkg, tn, mDuration);
} catch (RemoteException e) {
// Empty
}
}
show 방법 중 두 가지는 우리가 주의해야 할 것이다.(1) TN이 뭐야?(2) INotificationManager 서비스의 역할.이 두 가지 문제를 가지고 우리 Toast 원본의 탐색을 계속합시다.TN 소스
많은 질문들이 원본 코드를 읽으며 답을 찾을 수 있는데, 관건은 당신과 일치하는 인내심과 견지 여부에 있다.mTN의 실현은 Toast의 구조 함수에서 다음과 같이 원본이 된다.
public Toast(Context context) {
mContext = context;
mTN = new TN();
mTN.mY = context.getResources().getDimensionPixelSize(
com.android.internal.R.dimen.toast_y_offset);
mTN.mGravity = context.getResources().getInteger(
com.android.internal.R.integer.config_toastDefaultGravity);
}
그 다음에 우리는 TN류의 원본 코드에서 출발하여 TN의 작용을 탐색할 것이다.TN 소스는 다음과 같습니다.
private static class TN extends ITransientNotification.Stub {
final Runnable mShow = new Runnable() {
@Override
public void run() {
handleShow();
}
};
final Runnable mHide = new Runnable() {
@Override
public void run() {
handleHide();
// Don't do this in handleHide() because it is also invoked by handleShow()
mNextView = null;
}
};
private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();
final Handler mHandler = new Handler();
int mGravity;
int mX, mY;
float mHorizontalMargin;
float mVerticalMargin;
View mView;
View mNextView;
WindowManager mWM;
TN() {
// XXX This should be changed to use a Dialog, with a Theme.Toast
// defined that sets up the layout params appropriately.
final WindowManager.LayoutParams params = mParams;
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
params.windowAnimations = com.android.internal.R.style.Animation_Toast;
params.type = WindowManager.LayoutParams.TYPE_TOAST;
params.setTitle("Toast");
params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
/// M: [ALPS00517576] Support multi-user
params.privateFlags = WindowManager.LayoutParams.PRIVATE_FLAG_SHOW_FOR_ALL_USERS;
}
/**
* schedule handleShow into the right thread
*/
@Override
public void show() {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.post(mShow);
}
/**
* schedule handleHide into the right thread
*/
@Override
public void hide() {
if (localLOGV) Log.v(TAG, "HIDE: " + this);
mHandler.post(mHide);
}
public void handleShow() {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
final Configuration config = mView.getContext().getResources().getConfiguration();
final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
mParams.gravity = gravity;
if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
mParams.horizontalWeight = 1.0f;
}
if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
mParams.verticalWeight = 1.0f;
}
mParams.x = mX;
mParams.y = mY;
mParams.verticalMargin = mVerticalMargin;
mParams.horizontalMargin = mHorizontalMargin;
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
}
}
private void trySendAccessibilityEvent() {
AccessibilityManager accessibilityManager =
AccessibilityManager.getInstance(mView.getContext());
if (!accessibilityManager.isEnabled()) {
return;
}
// treat toasts as notifications since they are used to
// announce a transient piece of information to the user
AccessibilityEvent event = AccessibilityEvent.obtain(
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
event.setClassName(getClass().getName());
event.setPackageName(mView.getContext().getPackageName());
mView.dispatchPopulateAccessibilityEvent(event);
accessibilityManager.sendAccessibilityEvent(event);
}
public void handleHide() {
if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
if (mView != null) {
// note: checking parent() just to make sure the view has
// been added... i have seen cases where we get here when
// the view isn't yet added, so let's try not to crash.
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
mView = null;
}
}
}
원본 코드를 통해 우리는 계승 관계를 뚜렷하게 볼 수 있다. TN류는 ITransientNotification에서 계승되었다.프로세스 간 통신을 위한 Stub여기에 독자들이 모두 안드로이드 프로세스 간 통신의 기초를 가지고 있다고 가정한다.TN이 프로세스 간 통신에 사용되는 이상 TN류의 구체적인 역할은 Toast류의 리셋 대상이고 다른 프로세스는 TN류의 구체적인 대상을 호출하여 Toast의 디스플레이와 사라짐을 조작해야 한다고 생각하기 쉽다.TN 클래스는 ITransientNotification에서 상속됩니다.Stub,ITransientNotification.aidl은frameworks/base/core/java/android/app/ItransientNotification에 위치합니다.aidl, 소스는 다음과 같습니다.
package android.app;
/** @hide */
oneway interface ITransientNotification {
void show();
void hide();
}
ITransientNotification은 두 가지 방법인 show()와hide()를 정의했는데 그 구체적인 실현은 TN류에 있다.TN 클래스의 구현은 다음과 같습니다. /**
* schedule handleShow into the right thread
*/
@Override
public void show() {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.post(mShow);
}
/**
* schedule handleHide into the right thread
*/
@Override
public void hide() {
if (localLOGV) Log.v(TAG, "HIDE: " + this);
mHandler.post(mHide);
}
에서 Toast의 show와hide 방법의 실현은Handler 메커니즘을 바탕으로 한다는 것을 알 수 있다.TN 클래스의 Handler 구현은 다음과 같습니다. final Handler mHandler = new Handler();
그리고 TN 클래스에서 Looper가 발견되지 않았습니다.perpare() 및 Looper.loop() 방법.mHandler는 현재 스레드의 Looper 객체를 호출합니다.따라서 주 스레드(즉 UI 스레드)에서 Toast를 마음대로 호출할 수 있습니다.makeText 방법은 안드로이드 시스템이 메인 라인의 Looper를 초기화하는 데 도움을 주었기 때문입니다.하지만, 하위 라인에서 Toast를 호출하고 싶다면.makeText 방법은 반드시 먼저 Looper를 초기화해야 한다. 그렇지 않으면 보고할 것이다java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare() .Handler 메커니즘의 학습은 내가 전에 쓴 블로그를 참고할 수 있다.http://blog.csdn.net/wzy_1988/article/details/38346637.
이어서 mShow와 mHide의 실현을 따라가자. 두 가지 유형은 모두 Runnable이다.
final Runnable mShow = new Runnable() {
@Override
public void run() {
handleShow();
}
};
final Runnable mHide = new Runnable() {
@Override
public void run() {
handleHide();
// Don't do this in handleHide() because it is also invoked by handleShow()
mNextView = null;
}
};
에서 볼 수 있듯이 show와hide의 진정한 실현은 각각handleShow()와handleHide() 방법을 호출한 것이다.먼저 handleShow()의 구체적인 구현을 살펴보겠습니다. public void handleShow() {
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
final Configuration config = mView.getContext().getResources().getConfiguration();
final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
mParams.gravity = gravity;
if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
mParams.horizontalWeight = 1.0f;
}
if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
mParams.verticalWeight = 1.0f;
}
mParams.x = mX;
mParams.y = mY;
mParams.verticalMargin = mVerticalMargin;
mParams.horizontalMargin = mHorizontalMargin;
if (mView.getParent() != null) {
mWM.removeView(mView);
}
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
}
}
원본에서 Toast가 윈도우 관리자를 통해addView를 불러오는 것을 알 수 있습니다.따라서, hide 방법은 윈도우 관리자가 Toast 보기를 제거하기 위해removeView 방법을 호출하는 것입니다.총괄적으로 말하자면 TN류에 대한 원본 분석을 통해 우리는 TN류가 리셋 대상이고 다른 프로세스는 tn류의 show와hide 방법을 호출하여 이 Toast의 표시와 사라짐을 제어한다는 것을 알 수 있다.
NotificationManagerService
Toast 클래스의 show 방법으로 돌아가면 get Service를 호출하여 INotification Manager 서비스를 받을 수 있습니다. 원본 코드는 다음과 같습니다.
private static INotificationManager sService;
static private INotificationManager getService() {
if (sService != null) {
return sService;
}
sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
return sService;
}
INotificationManager 서비스를 받은 후 엔queueToast 방법을 호출하여 현재 Toast를 시스템의 Toast 대기열에 넣습니다.전달된 매개 변수는 각각 pkg, tn, mDuration이다.즉, 우리는 Toast를 통과했다.makeText(context, msg, Toast.LENGTH_SHOW).Toast () 를 보여 줍니다. 이 Toast는 현재 window에 바로 표시되는 것이 아니라, 시스템의 Toast 대기열에 먼저 들어간 다음, 시스템에서 리셋 대상 tn의 show와hide 방법을 호출해서 Toast를 표시하고 숨깁니다.이 INofitication Manager 인터페이스의 구체적인 실현 클래스는 Notification Manager 서비스 클래스입니다. 프레임워크/베이스/서비스/java/com/android/서비스/Notification Manager 서비스에 위치합니다.java.
먼저 Toast가 입대하는 함수인 enqueueToast를 분석해 보겠습니다. 원본 코드는 다음과 같습니다.
public void enqueueToast(String pkg, ITransientNotification callback, int duration)
{
// packageName null tn null, ,
if (pkg == null || callback == null) {
return ;
}
// (1) Toast
final boolean isSystemToast = isCallerSystem() || ("android".equals(pkg));
// toast pkg Toast pkg.NotificationManagerService HashSet , Toast
if (ENABLE_BLOCKED_TOASTS && !noteNotificationOp(pkg, Binder.getCallingUid()) && !areNotificationsEnabledForPackageInt(pkg)) {
if (!isSystemToast) {
return;
}
}
synchronized (mToastQueue) {
int callingPid = Binder.getCallingPid();
long callingId = Binder.clearCallingIdentity();
try {
ToastRecord record;
// (2) Toast
int index = indexOfToastLocked(pkg, callback);
// Toast ,
if (index >= 0) {
record = mToastQueue.get(index);
record.update(duration);
} else {
// Toast, pkg mToastQueue Toast , MAX_PACKAGE_NOTIFICATIONS
if (!isSystemToast) {
int count = 0;
final int N = mToastQueue.size();
for (int i=0; i<N; i++) {
final ToastRecord r = mToastQueue.get(i);
if (r.pkg.equals(pkg)) {
count++;
if (count >= MAX_PACKAGE_NOTIFICATIONS) {
Slog.e(TAG, "Package has already posted " + count
+ " toasts. Not showing more. Package=" + pkg);
return;
}
}
}
}
// Toast ToastRecord , mToastQueue
record = new ToastRecord(callingPid, pkg, callback, duration);
mToastQueue.add(record);
index = mToastQueue.size() - 1;
// (3) Toast
keepProcessAliveLocked(callingPid);
}
// (4) index 0, Toast , showNextToastLocked
if (index == 0) {
showNextToastLocked();
}
} finally {
Binder.restoreCallingIdentity(callingId);
}
}
}
보시다시피 나는 상술한 코드에 대해 간단명료한 주석을 하였다.코드는 상대적으로 간단하지만 4가지 표기 코드는 우리가 좀 더 연구해야 한다.
(1) 시스템 Toast인지 여부를 판단합니다.현재 Toast가 속한 프로세스의 패키지 이름이 "android"이면 시스템 Toast이고, 그렇지 않으면 isCallerSystem () 방법으로 판단할 수 있습니다.이 방법의 실현 원본은 다음과 같다.
boolean isUidSystem(int uid) {
final int appid = UserHandle.getAppId(uid);
return (appid == Process.SYSTEM_UID || appid == Process.PHONE_UID || uid == 0);
}
boolean isCallerSystem() {
return isUidSystem(Binder.getCallingUid());
}
isCallerSystem의 원본 코드도 비교적 간단하다. 바로 현재 Toast가 속한 프로세스의 uid가SYSTEM 인지 아닌지를 판단하는 것이다.UID、0、PHONE_UID 중 하나인 경우 시스템 Toast입니다.그렇지 않으면 시스템 Toast가 아닙니다.시스템 Toast인지 여부는 다음 소스 코드를 통해 알 수 있듯이 다음과 같은 두 가지 장점이 있습니다.
(2) 가입할 Toast가 시스템 Toast 대기열에 있는지 확인합니다.이것은 Pkg과 콜백을 비교하여 실현된 것으로 구체적인 원본 코드는 다음과 같다.
private int indexOfToastLocked(String pkg, ITransientNotification callback)
{
IBinder cbak = callback.asBinder();
ArrayList<ToastRecord> list = mToastQueue;
int len = list.size();
for (int i=0; i<len; i++) {
ToastRecord r = list.get(i);
if (r.pkg.equals(pkg) && r.callback.asBinder() == cbak) {
return i;
}
}
return -1;
}
상기 코드를 통해 우리는 Toast의 Pkg명칭과 tn대상이 일치하면 시스템은 이런 Toast를 같은 Toast로 간주한다는 결론을 얻을 수 있다.(3) 현재 Toast가 있는 프로세스를 프론트 데스크톱 프로세스로 설정합니다.소스는 다음과 같습니다.
private void keepProcessAliveLocked(int pid)
{
int toastCount = 0; // toasts from this pid
ArrayList<ToastRecord> list = mToastQueue;
int N = list.size();
for (int i=0; i<N; i++) {
ToastRecord r = list.get(i);
if (r.pid == pid) {
toastCount++;
}
}
try {
mAm.setProcessForeground(mForegroundToken, pid, toastCount > 0);
} catch (RemoteException e) {
// Shouldn't happen.
}
}
여기 mAm=Activity Manager Native.getDefault (), setProcessForeground 방법을 호출하여 현재 pid의 프로세스를 프론트 데스크톱 프로세스로 설정합니다. 시스템이 죽지 않을 것을 보장합니다.이것은finish가 현재Activity일 때 Toast가 현재 프로세스가 실행 중이기 때문에 표시할 수 있는 이유를 설명한다.(4) index가 0일 때 대기열 헤더의 Toast를 표시합니다.소스는 다음과 같습니다.
private void showNextToastLocked() {
// ToastRecord
ToastRecord record = mToastQueue.get(0);
while (record != null) {
try {
// Toast show Toast
record.callback.show();
scheduleTimeoutLocked(record);
return;
} catch (RemoteException e) {
Slog.w(TAG, "Object died trying to show notification " + record.callback
+ " in package " + record.pkg);
// remove it from the list and let the process die
int index = mToastQueue.indexOf(record);
if (index >= 0) {
mToastQueue.remove(index);
}
keepProcessAliveLocked(record.pid);
if (mToastQueue.size() > 0) {
record = mToastQueue.get(0);
} else {
record = null;
}
}
}
}
여기 토스트의 리셋 대상인 콜백이 tn 대상이다.다음으로 시스템 Toast의 디스플레이 시간은 왜 2s 또는 3.5s밖에 안 되는지 살펴보자. 관건은 schedule TimeoutLocked 방법의 실현에 있다.원리는 tn의 show 방법을 사용해서 Toast를 보여준 후에 schedule TimeoutLocked 방법을 사용해서 Toast를 사라져야 한다는 것이다.( 만약 여러분이 의문이 있다면 tn 대상의 하이드 방법으로 Toast를 사라지게 하는 것이 아니라 왜 여기서 schedule TimeoutLocked 방법으로 Toast를 사라지게 합니까?tn류의hide방법이 실행되자마자 Toast는 사라졌고 평소에 우리가 사용하던 Toast는 현재Activity에서 몇 초 동안 머물렀기 때문이다.어떻게 몇 초 머무르는 것을 실현합니까?원리는 schedule TimeoutLocked에서 MESSAGE를 보내는 것이다TIMEOUT 메시지는 tn 대상의hide 방법을 호출하지만, 이 메시지는 delay 지연이 있습니다. 여기도handler 메시지 메커니즘을 사용합니다.
private static final int LONG_DELAY = 3500; // 3.5 seconds
private static final int SHORT_DELAY = 2000; // 2 seconds
private void scheduleTimeoutLocked(ToastRecord r)
{
mHandler.removeCallbacksAndMessages(r);
Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r);
long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
mHandler.sendMessageDelayed(m, delay);
}
우선, MESSAGE를 직접 보낸 게 아니라는 걸 알았습니다TIMEOUT 메시지 대신 delay 지연이 있습니다.delay의 시간은 코드에서 "long delay = r.duration = Toast.LENGTH LONG? LONG DELAY:SHORT DELAY,"2s 또는 3.5s에 불과하다는 것을 알 수 있다. 이것은 왜 시스템 토스트의 표현 시간은 2s 또는 3.5s에 불과한지 설명한다.혼자 토스트에 있어요.makeText 방법에서 임의로 하나의duration을 전송하는 것은 무효입니다.
다음은 WorkerHandler에서 MESSAGE를 어떻게 처리하는지 살펴보겠습니다.TIMEOUT 메시지의mHandler 객체의 유형은 WorkerHandler이며 소스는 다음과 같습니다.
private final class WorkerHandler extends Handler
{
@Override
public void handleMessage(Message msg)
{
switch (msg.what)
{
case MESSAGE_TIMEOUT:
handleTimeout((ToastRecord)msg.obj);
break;
}
}
}
WorkerHandler 대 MESSAGETIMEOUT 형식의 메시지 처리는handlerTimeout 방법을 호출했습니다.handleTimeout 원본 코드를 계속 추적합니다. private void handleTimeout(ToastRecord record)
{
synchronized (mToastQueue) {
int index = indexOfToastLocked(record.pkg, record.callback);
if (index >= 0) {
cancelToastLocked(index);
}
}
}
handle Timeout 코드에서 현재 사라져야 할 Toast 소속 Toast Record 대상이 대기열에 있는지 먼저 판단하고 대기열에 있으면 cancelToast Locked (index) 방법을 호출합니다.진실이 우리의 눈앞에 떠올라 계속해서 원본을 추적한다. private void cancelToastLocked(int index) {
ToastRecord record = mToastQueue.get(index);
try {
record.callback.hide();
} catch (RemoteException e) {
// don't worry about this, we're about to remove it from
// the list anyway
}
mToastQueue.remove(index);
keepProcessAliveLocked(record.pid);
if (mToastQueue.size() > 0) {
// Show the next one. If the callback fails, this will remove
// it from the list, so don't assume that the list hasn't changed
// after this point.
showNextToastLocked();
}
}
하하, 여기 보시면 저희가 대상을 리셋하는 하이드 방법도 호출되었고 이 토스트 Record 대상도 mToastQueue에서 제거했습니다.여기까지 토스트의 완전한 디스플레이와 사라짐에 대한 설명이 끝났습니다.
이 내용에 흥미가 있습니까?
현재 기사가 여러분의 문제를 해결하지 못하는 경우 AI 엔진은 머신러닝 분석(스마트 모델이 방금 만들어져 부정확한 경우가 있을 수 있음)을 통해 가장 유사한 기사를 추천합니다:
다양한 언어의 JSONJSON은 Javascript 표기법을 사용하여 데이터 구조를 레이아웃하는 데이터 형식입니다. 그러나 Javascript가 코드에서 이러한 구조를 나타낼 수 있는 유일한 언어는 아닙니다. 저는 일반적으로 '객체'{}...
텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
CC BY-SA 2.5, CC BY-SA 3.0 및 CC BY-SA 4.0에 따라 라이센스가 부여됩니다.