C # 이벤트 및 스레드 안전성
최신 정보
C # 6 부터이 질문에 대한 답변은 다음과 같습니다.
SomeEvent?.Invoke(this, e);
다음과 같은 조언을 자주 듣거나 읽습니다.
이벤트를 확인하고 시작하기 전에 항상 이벤트 사본을 만드 null
십시오. 이렇게하면 null
null을 확인하는 위치 와 이벤트 를 발생시키는 위치 사이에 이벤트가 발생하는 스레딩 관련 잠재적 문제가 제거됩니다 .
// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;
if (copy != null)
copy(this, EventArgs.Empty); // Call any handlers on the copied list
업데이트 : 이벤트 멤버가 일시적이어야 할 수 있다는 최적화에 대해 읽었지만 Jon Skeet은 CLR이 사본을 최적화하지 않는다고 대답했습니다.
그러나이 문제가 발생하기 위해서는 다른 스레드가 다음과 같은 작업을 수행해야합니다.
// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;
// Good, now we can be certain that OnTheEvent will not run...
실제 순서는 다음과 같습니다.
// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;
// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;
// Good, now we can be certain that OnTheEvent will not run...
if (copy != null)
copy(this, EventArgs.Empty); // Call any handlers on the copied list
OnTheEvent
저자가 구독을 취소 한 후에 실행 되는 요점은 이러한 일이 발생하지 않도록 특별히 구독을 취소 한 것입니다. 실제로 필요한 것은 접근 자 add
와 remove
접근 자 에서 적절한 동기화를 통해 사용자 정의 이벤트 구현입니다 . 또한 이벤트가 발생하는 동안 잠금이 유지되면 교착 상태가 발생할 수 있습니다.
그래서이있다 화물 숭배 프로그래밍 ? 그런 식으로 보입니다-많은 사람들이 코드를 여러 스레드로부터 보호하기 위해이 단계를 수행해야합니다. 실제로 이벤트가 멀티 스레드 디자인의 일부로 사용되기 전에 이것보다 훨씬 더주의를 기울여야 할 것 같습니다 . 따라서 추가 관리를하지 않는 사람들은이 조언을 무시할 수도 있습니다. 단일 스레드 프로그램에서는 문제가되지 않으며 실제로 volatile
대부분의 온라인 예제 코드가없는 경우에는 조언이 없을 수도 있습니다. 전혀 효과.
(그리고 delegate { }
먼저 멤버를 확인할 필요가 없도록 멤버 선언에 빈 을 할당하는 것이 훨씬 간단 하지 null
않습니까?)
업데이트 :명확하지 않은 경우 모든 상황에서 null 참조 예외를 피하기 위해 조언의 의도를 파악했습니다. 내 요점은이 특정 null 참조 예외가 다른 스레드가 이벤트에서 delisting하는 경우에만 발생할 수 있으며 그 이유는 해당 기술을 통해 더 이상 호출이 수신되지 않도록하는 것입니다. . 경쟁 조건을 숨기고있을 것입니다. 공개하는 것이 좋습니다. 이 null 예외는 구성 요소의 남용을 감지하는 데 도움이됩니다. 컴포넌트가 악용되지 않도록하려면 WPF의 예를 따르십시오. 스레드 ID를 생성자에 저장 한 후 다른 스레드가 컴포넌트와 직접 상호 작용하려고 시도하면 예외가 발생합니다. 또는 진정한 스레드 안전 구성 요소를 구현하십시오 (쉬운 작업은 아님).
그래서 나는 단지이 카피 / 체크 관용구를하는 것은화물 컬트 프로그래밍이며, 코드에 혼란과 소음을 추가한다고 주장한다. 실제로 다른 스레드로부터 보호하려면 더 많은 작업이 필요합니다.
Eric Lippert의 블로그 게시물에 대한 답변으로 업데이트 :
따라서 이벤트 처리기에서 놓친 중요한 점이 있습니다. "이벤트 처리기가 구독 취소 된 후에도 호출 될 때 이벤트 처리기가 강력해야합니다."따라서 분명히 이벤트의 가능성 만 신경 써야합니다. 대리자 null
. 이벤트 핸들러에 대한 요구 사항이 어디에나 문서화되어 있습니까?
"이 문제를 해결하는 다른 방법이 있습니다. 예를 들어, 처리기가 초기화되지 않은 빈 동작을 갖도록 초기화하는 것입니다. 그러나 null 검사를 수행하는 것이 표준 패턴입니다."
그래서 내 질문의 나머지 부분은 왜 명시 적 null 검사가 "표준 패턴"입니까? 빈 델리게이트를 할당하는 대안 = delegate {}
은 이벤트 선언에 추가하기 만하면되므로 이벤트가 발생하는 모든 장소에서 냄새가 많은 작은 행사가 필요 없습니다. 빈 델리게이트가 인스턴스화하기에 저렴하다는 것을 쉽게 알 수 있습니다. 아니면 여전히 뭔가 빠졌습니까?
Jon Skeet이 제안한 것처럼 2005 년에했던 것처럼 죽지 않은 .NET 1.x 조언 일 것입니다.
JIT는 조건으로 인해 첫 번째 부분에서 이야기하는 최적화를 수행 할 수 없습니다. 나는 이것이 오래 전에 유령으로 제기되었다는 것을 알고 있지만 유효하지 않습니다. (나는 얼마 전에 Joe Duffy 또는 Vance Morrison으로 확인했지만 어느 것을 기억할 수 없습니다.)
휘발성 수정자가 없으면 로컬 복사본이 오래되었을 수 있지만 그게 전부입니다. 원인이되지 않습니다 NullReferenceException
.
물론 그렇습니다. 경쟁 조건이 있지만 항상있을 것입니다. 코드를 다음과 같이 변경한다고 가정합니다.
TheEvent(this, EventArgs.Empty);
이제 해당 대리자의 호출 목록에 1000 개의 항목이 있다고 가정하십시오. 다른 스레드가 목록의 끝 근처에서 핸들러를 구독 취소하기 전에 목록 시작시 조치가 실행되었을 가능성이 있습니다. 그러나 해당 핸들러는 새 목록이므로 계속 실행됩니다. (대표는 불변이다.) 내가 볼 수있는 한, 이것은 불가피하다.
빈 델리게이트를 사용하면 nullity 검사를 피할 수 있지만 경쟁 조건을 수정하지는 않습니다. 또한 항상 변수의 최신 값을 "볼"것을 보장하지는 않습니다.
나는 이것을하는 확장 방법을 향해 많은 사람들을 봅니다 ...
public static class Extensions
{
public static void Raise<T>(this EventHandler<T> handler,
object sender, T args) where T : EventArgs
{
if (handler != null) handler(sender, args);
}
}
이벤트를 발생시키는 더 좋은 구문을 제공합니다 ...
MyEvent.Raise( this, new MyEventArgs() );
또한 메소드 호출시 캡처되므로 로컬 사본을 제거합니다.
"왜 '표준 패턴'을 명시 적으로 null 검사합니까?"
그 이유는 null-check가 더 성능이 좋기 때문일 수 있습니다.
이벤트가 생성 될 때 항상 빈 델리게이트를 구독하면 몇 가지 오버 헤드가 발생합니다.
- 빈 델리게이트 구성 비용.
- 그것을 포함하기 위해 델리게이트 체인을 구성하는 비용.
- 이벤트가 발생할 때마다 무의미한 대리인을 호출하는 비용.
(UI 컨트롤에는 이벤트가 많을 때가 많지만 대부분 구독하지는 않습니다. 각 이벤트에 더미 구독자를 만든 다음 호출하면 성능이 크게 저하 될 수 있습니다.)
subscribe-empty-delegate 접근법의 영향을 확인하기 위해 약간의 성능 테스트를 수행했으며 결과는 다음과 같습니다.
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took: 432ms
OnClassicNullCheckedEvent took: 490ms
OnPreInitializedEvent took: 614ms <--
Subscribing an empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took: 674ms
OnClassicNullCheckedEvent took: 674ms
OnPreInitializedEvent took: 2041ms <--
Subscribing another empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took: 2011ms
OnClassicNullCheckedEvent took: 2061ms
OnPreInitializedEvent took: 2246ms <--
Done
가입자가 0 명 또는 1 명인 경우 (이벤트가 많은 UI 컨트롤에 공통), 빈 델리게이트로 사전 초기화 된 이벤트는 현저하게 느립니다 (5 천만 회 이상 반복).
자세한 내용과 소스 코드를 보려면 이 질문을하기 바로 전에 게시 한 .NET 이벤트 호출 스레드 안전성 에 대한이 블로그 게시물을 방문하십시오 (!).
(테스트 설정에 결함이있을 수 있으므로 소스 코드를 다운로드하여 직접 검사하십시오. 의견은 대단히 감사합니다.)
나는이 읽기를 정말로 즐겼습니다. events라는 C # 기능을 사용하려면 필요하지만!
컴파일러에서 이것을 수정하지 않겠습니까? 이 게시물을 읽는 MS 사람들이 있다는 것을 알고 있습니다.
1-Null 문제 ) 왜 이벤트를 null 대신에 비어있는 것으로 만드십시오. null 검사를 위해 몇 줄의 코드를 저장하거나 = delegate {}
선언 을 고수 해야합니까? 컴파일러가 Empty 케이스를 처리하도록하자. IE는 아무것도하지 않는다! 모든 것이 이벤트 제작자에게 중요한 경우, .Empty를 확인하고 관심있는 모든 작업을 수행 할 수 있습니다! 그렇지 않으면 모든 null 확인 / 대리자 추가가 문제를 해결하는 것입니다!
솔직히 나는 모든 이벤트-일명 상용구 코드 로이 작업을 수행하는 데 지쳤습니다!
public event Action<thisClass, string> Some;
protected virtual void DoSomeEvent(string someValue)
{
var e = Some; // avoid race condition here!
if(null != e) // avoid null condition here!
e(this, someValue);
}
2-경쟁 조건 문제 ) Eric의 블로그 게시물을 읽었으며 H (핸들러)가 자체를 역 참조 할 때 처리해야하지만 이벤트를 변경할 수없는 스레드로 만들 수 없다는 데 동의합니다. IE, 생성시 잠금 플래그를 설정하여 호출 될 때마다 모든 구독 및 구독을 잠그는 동안 실행 중입니까?
결론 ,
현대 언어는 우리에게 이런 문제를 해결하지 않습니까?
C #을 통한 CLR 책의 Jeffrey Richter에 따르면 올바른 방법은 다음과 같습니다.
// Copy a reference to the delegate field now into a temporary field for thread safety
EventHandler<EventArgs> temp =
Interlocked.CompareExchange(ref NewMail, null, null);
// If any methods registered interest with our event, notify them
if (temp != null) temp(this, e);
참조 사본을 강제 실행하기 때문입니다. 자세한 내용은이 책의 이벤트 섹션을 참조하십시오.
이벤트 핸들러가 등록 해제 된 후에 실행되지 않도록이 디자인 패턴을 사용했습니다. 성능 프로파일 링을 시도하지 않았지만 지금까지는 잘 작동하고 있습니다.
private readonly object eventMutex = new object();
private event EventHandler _onEvent = null;
public event EventHandler OnEvent
{
add
{
lock(eventMutex)
{
_onEvent += value;
}
}
remove
{
lock(eventMutex)
{
_onEvent -= value;
}
}
}
private void HandleEvent(EventArgs args)
{
lock(eventMutex)
{
if (_onEvent != null)
_onEvent(args);
}
}
요즘에는 주로 Android 용 Mono로 작업하고 있으며 활동이 백그라운드로 전송 된 후 View를 업데이트하려고하면 Android가 좋아하지 않는 것 같습니다.
C # 6 이상에서는 다음과 같이 new .? operator
를 사용하여 코드를 단순화 할 수 있습니다 .
TheEvent?.Invoke(this, EventArgs.Empty);
이 관행은 특정 작업 순서를 시행하는 것이 아닙니다. 실제로 null 참조 예외를 피하는 것입니다.
경쟁 조건이 아닌 null 참조 예외에 관심을 가진 사람들의 추론에는 심리적 심층 연구가 필요합니다. null 참조 문제를 해결하는 것이 훨씬 쉽다는 사실과 관련이 있다고 생각합니다. 일단 수정되면 코드에 큰 "미션 달성"배너를 매달고 비 행복을 풉니 다.
참고 : 경쟁 조건을 수정하려면 핸들러 실행 여부를 동기 플래그 트랙을 사용해야합니다.
그래서 나는 파티에 조금 늦었다. :)
구독자가없는 이벤트를 표시하기 위해 널 오브젝트 패턴 대신 널을 사용하는 경우이 시나리오를 고려하십시오. 이벤트를 호출해야하지만 객체 (EventArgs) 구성은 쉽지 않으며 일반적인 경우 이벤트에 가입자가 없습니다. 인수를 구성하고 이벤트를 호출하기 위해 처리 노력을 기울이기 전에 가입자가 있는지 확인하기 위해 코드를 최적화 할 수 있다면 도움이 될 것입니다.
이를 염두에두고 해결책은 "제로 가입자가 0이면 null로 표시됩니다."라고 말하는 것입니다. 그런 다음 값 비싼 작업을 수행하기 전에 null 검사를 수행하면됩니다. 이 작업을 수행하는 또 다른 방법은 Delegate 유형에 Count 속성을 사용하는 것이 었으므로 myDelegate.Count> 0 인 경우에만 비싼 작업을 수행합니다. Count 속성을 사용하면 원래 문제를 해결하는 다소 좋은 패턴입니다. 최적화를 허용하고 NullReferenceException을 발생시키지 않고 호출 할 수있는 멋진 속성을 가지고 있습니다.
그러나 델리게이트는 참조 유형이므로 널이 될 수 있습니다. 아마도이 사실을 숨기고 이벤트에 대해 널 오브젝트 패턴 만 지원하는 좋은 방법이 없었을 것이므로, 대안으로 개발자가 널 (null) 및 제로 (subscriber) 가입자를 모두 확인해야 할 수도 있습니다. 그것은 현재 상황보다 더 나빠질 것입니다.
참고 : 이것은 순수한 추측입니다. .NET 언어 또는 CLR과 관련이 없습니다.
단일 스레드 응용 프로그램의 경우 문제가되지 않습니다.
그러나 이벤트를 노출하는 구성 요소를 만드는 경우 구성 요소 소비자가 멀티 스레딩을하지 않을 것이라는 보장은 없습니다.이 경우 최악의 경우를 대비해야합니다.
빈 대리자를 사용하면 문제가 해결되지만 이벤트를 호출 할 때마다 성능이 저하되고 GC에 영향을 줄 수 있습니다.
당신이 이것을하기 위해 소비자 trie가 구독을 취소하는 것이 옳습니다. 그러나 그들이 임시 사본을 지나서 만들었다면, 이미 전송중인 메시지를 고려하십시오.
임시 변수를 사용하지 않고 빈 대리자를 사용하지 않고 누군가 구독을 취소하면 치명적인 null 참조 예외가 발생하므로 비용이 가치가 있다고 생각합니다.
재사용 가능한 구성 요소의 정적 메서드 등에서 이러한 종류의 잠재적 스레딩 불량을 방지하고 정적 이벤트를 만들지 않기 때문에 실제로 이것이 큰 문제라고 생각한 적이 없습니다.
내가 잘못하고 있습니까?
건설중인 모든 이벤트를 연결하고 혼자 두십시오. 이 게시물의 마지막 단락에서 설명 하듯이 Delegate 클래스의 디자인은 다른 사용법을 올바르게 처리 할 수 없습니다.
우선, 이벤트 핸들러 가 이미 알림 응답 여부 / 방법에 대해 동기화 된 결정을해야 할 때 이벤트 알림 을 가로 채 려고 할 필요가 없습니다 .
통보 될 수있는 모든 것이 통보되어야합니다. 이벤트 핸들러가 알림을 올바르게 처리하는 경우 (즉, 신뢰할 수있는 애플리케이션 상태에 액세스하고 적절한 경우에만 응답하는 경우) 언제든지 알림을 보내고 적절하게 응답한다고 신뢰하는 것이 좋습니다.
핸들러가 이벤트가 발생했다는 사실을 통지해서는 안되는 것은 이벤트가 실제로 발생하지 않은 경우입니다. 따라서 핸들러에 알림을 표시하지 않으려면 이벤트 생성을 중지하십시오 (예 : 컨트롤을 비활성화하거나 이벤트를 감지하고 이벤트를 먼저 발생시키는 역할을하는 모든 것을 비활성화).
솔직히 델리게이트 클래스는 구할 수 없다고 생각합니다. MulticastDelegate 로의 합병 / 전환은 이벤트의 (유용한) 정의를 단일 순간에 발생하는 것에서 시간 범위에 걸쳐 발생하는 것으로 효과적으로 변경했기 때문에 큰 실수였습니다. 이러한 변경에는 논리적으로 단일 인스턴트로 다시 축소 할 수있는 동기화 메커니즘이 필요하지만 MulticastDelegate에는 그러한 메커니즘이 없습니다. 동기화는 전체 시간 범위 또는 이벤트가 발생하는 순간을 포함해야하므로 응용 프로그램이 이벤트 처리를 시작하기 위해 동기화 된 결정을 내리면 이벤트 처리가 완전히 완료됩니다 (트랜잭션). MulticastDelegate / Delegate 하이브리드 클래스 인 블랙 박스를 사용하면 거의 불가능하므로단일 가입자를 사용하고 /하거나 핸들러 체인을 사용 / 수정하는 동안 제거 할 수있는 동기화 핸들이있는 사용자 고유의 MulticastDelegate를 구현하십시오 . 대안은 모든 처리기에서 동기화 / 트랜잭션 무결성을 중복으로 구현하는 것이기 때문에이 방법을 권장합니다. 이는 엄청나게 / 불필요하게 복잡 할 수 있습니다.
여기를 참조 하십시오 : http://www.danielfortunov.com/software/%24daniel_fortunovs_adventures_in_software_development/2009/04/23/net_event_invocation_thread_safety 이것은 올바른 해결책이며 다른 해결 방법 대신 항상 사용해야합니다.
“무의미한 익명 메소드로 초기화하여 내부 호출 목록에 항상 하나 이상의 멤버가 있는지 확인할 수 있습니다. 익명의 메서드를 참조 할 수있는 외부 당사자가 없기 때문에 외부 당사자가 메서드를 제거 할 수 없으므로 대리자는 절대로 null이되지 않습니다.”— Juval Löwy의 Programming .NET Components, 2 판
public static event EventHandler<EventArgs> PreInitializedEvent = delegate { };
public static void OnPreInitializedEvent(EventArgs e)
{
// No check required - event will never be null because
// we have subscribed an empty anonymous delegate which
// can never be unsubscribed. (But causes some overhead.)
PreInitializedEvent(null, e);
}
질문이 C # "event"유형으로 제한되어 있다고 생각하지 않습니다. 이 제한을 없애고 휠을 조금만 다시 발명하고이 라인을 따라 무언가를 해보세요.
- 상승 중일 때 임의의 스레드에서 구독 / 구독 해제 기능 (레이스 조건 제거)
- 클래스 수준에서 + = 및-=에 대한 연산자 오버로드
- 일반 발신자 정의 대리인
유용한 토론에 감사드립니다. 나는 최근 에이 문제를 연구하고 약간 느린 클래스를 만들었지 만 폐기 된 객체에 대한 호출을 피할 수 있습니다.
여기서 중요한 점은 이벤트가 발생하더라도 호출 목록을 수정할 수 있다는 것입니다.
/// <summary>
/// Thread safe event invoker
/// </summary>
public sealed class ThreadSafeEventInvoker
{
/// <summary>
/// Dictionary of delegates
/// </summary>
readonly ConcurrentDictionary<Delegate, DelegateHolder> delegates = new ConcurrentDictionary<Delegate, DelegateHolder>();
/// <summary>
/// List of delegates to be called, we need it because it is relatevely easy to implement a loop with list
/// modification inside of it
/// </summary>
readonly LinkedList<DelegateHolder> delegatesList = new LinkedList<DelegateHolder>();
/// <summary>
/// locker for delegates list
/// </summary>
private readonly ReaderWriterLockSlim listLocker = new ReaderWriterLockSlim();
/// <summary>
/// Add delegate to list
/// </summary>
/// <param name="value"></param>
public void Add(Delegate value)
{
var holder = new DelegateHolder(value);
if (!delegates.TryAdd(value, holder)) return;
listLocker.EnterWriteLock();
delegatesList.AddLast(holder);
listLocker.ExitWriteLock();
}
/// <summary>
/// Remove delegate from list
/// </summary>
/// <param name="value"></param>
public void Remove(Delegate value)
{
DelegateHolder holder;
if (!delegates.TryRemove(value, out holder)) return;
Monitor.Enter(holder);
holder.IsDeleted = true;
Monitor.Exit(holder);
}
/// <summary>
/// Raise an event
/// </summary>
/// <param name="args"></param>
public void Raise(params object[] args)
{
DelegateHolder holder = null;
try
{
// get root element
listLocker.EnterReadLock();
var cursor = delegatesList.First;
listLocker.ExitReadLock();
while (cursor != null)
{
// get its value and a next node
listLocker.EnterReadLock();
holder = cursor.Value;
var next = cursor.Next;
listLocker.ExitReadLock();
// lock holder and invoke if it is not removed
Monitor.Enter(holder);
if (!holder.IsDeleted)
holder.Action.DynamicInvoke(args);
else if (!holder.IsDeletedFromList)
{
listLocker.EnterWriteLock();
delegatesList.Remove(cursor);
holder.IsDeletedFromList = true;
listLocker.ExitWriteLock();
}
Monitor.Exit(holder);
cursor = next;
}
}
catch
{
// clean up
if (listLocker.IsReadLockHeld)
listLocker.ExitReadLock();
if (listLocker.IsWriteLockHeld)
listLocker.ExitWriteLock();
if (holder != null && Monitor.IsEntered(holder))
Monitor.Exit(holder);
throw;
}
}
/// <summary>
/// helper class
/// </summary>
class DelegateHolder
{
/// <summary>
/// delegate to call
/// </summary>
public Delegate Action { get; private set; }
/// <summary>
/// flag shows if this delegate removed from list of calls
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// flag shows if this instance was removed from all lists
/// </summary>
public bool IsDeletedFromList { get; set; }
/// <summary>
/// Constuctor
/// </summary>
/// <param name="d"></param>
public DelegateHolder(Delegate d)
{
Action = d;
}
}
}
그리고 사용법은 다음과 같습니다.
private readonly ThreadSafeEventInvoker someEventWrapper = new ThreadSafeEventInvoker();
public event Action SomeEvent
{
add { someEventWrapper.Add(value); }
remove { someEventWrapper.Remove(value); }
}
public void RaiseSomeEvent()
{
someEventWrapper.Raise();
}
테스트
다음과 같은 방법으로 테스트했습니다. 다음과 같은 객체를 생성하고 파괴하는 스레드가 있습니다.
var objects = Enumerable.Range(0, 1000).Select(x => new Bar(foo)).ToList();
Thread.Sleep(10);
objects.ForEach(x => x.Dispose());
A의 Bar
(a 수신기 객체) 생성자 I가 가입 SomeEvent
거부에 (상기와 같이 구현되는) 및 Dispose
:
public Bar(Foo foo)
{
this.foo = foo;
foo.SomeEvent += Handler;
}
public void Handler()
{
if (disposed)
Console.WriteLine("Handler is called after object was disposed!");
}
public void Dispose()
{
foo.SomeEvent -= Handler;
disposed = true;
}
또한 루프에서 이벤트를 발생시키는 몇 개의 스레드가 있습니다.
이러한 모든 작업이 동시에 수행됩니다. 많은 리스너가 만들어지고 소멸되며 동시에 이벤트가 시작됩니다.
경쟁 조건이있는 경우 콘솔에 메시지가 표시되지만 비어 있습니다. 그러나 평소대로 clr 이벤트를 사용하면 경고 메시지가 가득합니다. 따라서 C #에서 스레드 안전 이벤트를 구현할 수 있다고 결론을 내릴 수 있습니다.
어떻게 생각해?
참고 URL : https://stackoverflow.com/questions/786383/c-sharp-events-and-thread-safety
'IT story' 카테고리의 다른 글
iOS 테스트 / 사양 TDD / BDD 및 통합 및 수락 테스트 (0) | 2020.04.16 |
---|---|
Android 용 TextView 자동 맞춤 (0) | 2020.04.16 |
GitHub에서 다른 사람의 코드에 어떻게 기여합니까? (0) | 2020.04.16 |
apple-touch-icon-precomposed.png에 오류가 발생하는 이유는 무엇입니까? (0) | 2020.04.16 |
도트 파일에서 "rc"의 의미 (0) | 2020.04.16 |