Xamarin.iOS의 CallKit

iOS 10의 새로운 CallKit API는 VOIP 앱이 i전화 UI와 통합되고 최종 사용자에게 친숙한 인터페이스와 환경을 제공하는 방법을 제공합니다. 이 API를 사용하면 사용자가 iOS 디바이스의 잠금 화면에서 VOIP 호출을 보고 상호 작용하고 전화 앱의 즐겨찾기 및 최근 보기로 연락처를 관리할 수 있습니다.

CallKit 정보

Apple에 따르면 CallKit은 타사 VOIP(Voice Over IP) 앱을 iOS 10의 자사 환경으로 승격하는 새로운 프레임워크입니다. CallKit API를 사용하면 VOIP 앱이 i전화 UI와 통합되고 최종 사용자에게 친숙한 인터페이스와 환경을 제공할 수 있습니다. 기본 제공 전화 앱과 마찬가지로 사용자는 iOS 디바이스의 잠금 화면에서 VOIP 호출을 보고 상호 작용하고 전화 앱의 즐겨찾기최근 보기로 연락처를 관리할 수 있습니다.

또한 CallKit API는 전화 번호를 이름(발신자 ID)과 연결하거나 번호를 차단해야 하는 경우 시스템에 알릴 수 있는 앱 확장을 만드는 기능을 제공합니다(통화 차단).

기존 VOIP 앱 환경

새로운 CallKit API 및 해당 기능을 논의하기 전에 MonkeyCall이라는 가상의 VOIP 앱을 사용하여 iOS 9(이하)의 타사 VOIP 앱에서 현재 사용자 환경을 살펴보세요. MonkeyCall은 사용자가 기존 iOS API를 사용하여 VOIP 호출을 보내고 받을 수 있는 간단한 앱입니다.

현재 사용자가 MonkeyCall에서 수신 전화를 받고 i전화 잠긴 경우 잠금 화면에서 받은 알림은 다른 유형의 알림(예: 메시지 또는 메일 앱의 알림)과 구별할 수 없습니다.

사용자가 통화에 응답하려는 경우 통화를 수락하고 대화를 시작하기 전에 MonkeyCall 알림을 슬라이드하여 앱을 열고 암호(또는 사용자 터치 ID)를 입력하여 휴대폰의 잠금을 해제해야 합니다.

휴대 전화의 잠금을 해제하는 경우 경험은 똑같이 번거롭습니다. 다시 말하지만, 들어오는 MonkeyCall 호출은 화면 위쪽에서 슬라이드되는 표준 알림 배너로 표시됩니다. 알림은 일시적이므로 사용자가 알림 센터를 열고 응답할 특정 알림을 찾은 다음, MonkeyCall 앱을 수동으로 찾거나 시작하도록 강요하여 쉽게 놓칠 수 있습니다.

CallKit VOIP 앱 환경

MonkeyCall 앱에서 새 CallKit API를 구현하면 iOS 10에서 들어오는 VOIP 호출에 대한 사용자의 환경이 크게 향상될 수 있습니다. 휴대폰이 위에서 잠겨 있을 때 VOIP 전화를 받는 사용자의 예를 들어 보세요. CallKit을 구현하면 기본 제공 전화 앱에서 호출을 받은 경우와 마찬가지로 i전화 잠금 화면에 전체 화면, 네이티브 UI 및 표준 살짝 밀기-응답 기능이 표시됩니다.

다시 말하지만, MonkeyCall VOIP 호출이 수신될 때 i전화 잠금이 해제되면 기본 제공 전화 앱의 동일한 전체 화면, 네이티브 UI 및 표준 살짝 밀기-응답 및 탭 투 거절 기능이 표시되고 MonkeyCall은 사용자 지정 벨소리를 재생하는 옵션이 있습니다.

CallKit은 VoIP 호출이 다른 유형의 통화와 상호 작용하고, 기본 제공 최근 항목 및 즐겨찾기 목록에 표시되고, 기본 제공 방해 금지 및 차단 기능을 사용하고, Siri에서 MonkeyCall 통화를 시작하고, 사용자가 연락처 앱의 사람들에게 MonkeyCall 호출을 할당할 수 있는 기능을 제공하는 MonkeyCall에 추가 기능을 제공합니다.

다음 섹션에서는 CallKit 아키텍처, 수신 및 발신 호출 흐름 및 CallKit API에 대해 자세히 설명합니다.

CallKit 아키텍처

iOS 10에서 Apple은 CarPlay에서 수행된 호출이 CallKit을 통해 시스템 UI에 알려지도록 모든 시스템 서비스에서 CallKit을 채택했습니다. 아래 예제에서 MonkeyCall은 CallKit을 채택하므로 이러한 기본 제공 System Services와 동일한 방식으로 시스템에 알려져 있으며 동일한 기능을 모두 가져옵니다.

The CallKit Service Stack

위의 다이어그램에서 MonkeyCall 앱을 자세히 살펴보세요. 앱에는 자체 네트워크와 통신하기 위한 모든 코드가 포함되어 있으며 자체 사용자 인터페이스가 포함되어 있습니다. CallKit에서 시스템과 통신하도록 연결됩니다.

MonkeyCall App Architecture

CallKit에는 앱에서 사용하는 두 가지 기본 인터페이스가 있습니다.

  • CXProvider - 이를 통해 MonkeyCall 앱은 발생할 수 있는 대역 외 알림을 시스템에 알릴 수 있습니다.
  • CXCallController - MonkeyCall 앱이 로컬 사용자 작업을 시스템에 알릴 수 있도록 허용합니다.

The CXProvider

위에서 CXProvider 설명한 대로 앱에서 발생할 수 있는 대역 외 알림을 시스템에 알릴 수 있습니다. 이러한 알림은 로컬 사용자 작업으로 인해 발생하지 않지만 들어오는 호출과 같은 외부 이벤트로 인해 발생합니다.

앱은 다음을 CXProvider 위해 사용해야 합니다.

  • 시스템에 들어오는 호출을 보고합니다.
  • 나가는 호출이 시스템에 연결되었음을 보고합니다.
  • 시스템에 대한 호출을 종료하는 원격 사용자를 보고합니다.

앱이 시스템과 통신하려는 경우 클래스를 CXCallUpdate 사용하고 시스템이 앱과 통신해야 하는 경우 클래스를 CXAction 사용합니다.

Communicating with the system via a CXProvider

The CXCallController

이를 통해 앱은 CXCallController VOIP 호출을 시작하는 사용자와 같은 로컬 사용자 작업을 시스템에 알릴 수 있습니다. 앱을 구현하면 CXCallController 시스템의 다른 유형의 호출과 상호 작용하게 됩니다. 예를 들어 활성 전화 통신 통화가 이미 진행 중인 CXCallController 경우 VOIP 앱이 해당 호출을 보류 상태로 두고 VOIP 통화를 시작하거나 응답하도록 허용할 수 있습니다.

앱은 다음을 CXCallController 위해 사용해야 합니다.

  • 사용자가 시스템에 대한 발신 호출을 시작한 경우 보고합니다.
  • 사용자가 시스템에 들어오는 호출에 응답할 때 보고합니다.
  • 사용자가 시스템 호출을 종료할 때 보고합니다.

앱이 로컬 사용자 작업을 시스템에 전달하려는 경우 클래스를 CXTransaction 사용합니다.

Reporting to the system using a CXCallController

CallKit 구현

다음 섹션에서는 Xamarin.iOS VOIP 앱에서 CallKit을 구현하는 방법을 보여줍니다. 예를 들어 이 문서에서는 가상의 MonkeyCall VOIP 앱의 코드를 사용합니다. 여기에 제시된 코드는 여러 지원 클래스를 나타내며, CallKit 특정 부분은 다음 섹션에서 자세히 설명합니다.

ActiveCall 클래스

ActiveCall 클래스는 MonkeyCall 앱에서 다음과 같이 현재 활성화된 VOIP 호출에 대한 모든 정보를 보유하는 데 사용됩니다.

using System;
using CoreFoundation;
using Foundation;

namespace MonkeyCall
{
    public class ActiveCall
    {
        #region Private Variables
        private bool isConnecting;
        private bool isConnected;
        private bool isOnhold;
        #endregion

        #region Computed Properties
        public NSUuid UUID { get; set; }
        public bool isOutgoing { get; set; }
        public string Handle { get; set; }
        public DateTime StartedConnectingOn { get; set;}
        public DateTime ConnectedOn { get; set;}
        public DateTime EndedOn { get; set; }

        public bool IsConnecting {
            get { return isConnecting; }
            set {
                isConnecting = value;
                if (isConnecting) StartedConnectingOn = DateTime.Now;
                RaiseStartingConnectionChanged ();
            }
        }

        public bool IsConnected {
            get { return isConnected; }
            set {
                isConnected = value;
                if (isConnected) {
                    ConnectedOn = DateTime.Now;
                } else {
                    EndedOn = DateTime.Now;
                }
                RaiseConnectedChanged ();
            }
        }

        public bool IsOnHold {
            get { return isOnhold; }
            set {
                isOnhold = value;
            }
        }
        #endregion

        #region Constructors
        public ActiveCall ()
        {
        }

        public ActiveCall (NSUuid uuid, string handle, bool outgoing)
        {
            // Initialize
            this.UUID = uuid;
            this.Handle = handle;
            this.isOutgoing = outgoing;
        }
        #endregion

        #region Public Methods
        public void StartCall (ActiveCallbackDelegate completionHandler)
        {
            // Simulate the call starting successfully
            completionHandler (true);

            // Simulate making a starting and completing a connection
            DispatchQueue.MainQueue.DispatchAfter (new DispatchTime(DispatchTime.Now, 3000), () => {
                // Note that the call is starting
                IsConnecting = true;

                // Simulate pause before connecting
                DispatchQueue.MainQueue.DispatchAfter (new DispatchTime (DispatchTime.Now, 1500), () => {
                    // Note that the call has connected
                    IsConnecting = false;
                    IsConnected = true;
                });
            });
        }

        public void AnswerCall (ActiveCallbackDelegate completionHandler)
        {
            // Simulate the call being answered
            IsConnected = true;
            completionHandler (true);
        }

        public void EndCall (ActiveCallbackDelegate completionHandler)
        {
            // Simulate the call ending
            IsConnected = false;
            completionHandler (true);
        }
        #endregion

        #region Events
        public delegate void ActiveCallbackDelegate (bool successful);
        public delegate void ActiveCallStateChangedDelegate (ActiveCall call);

        public event ActiveCallStateChangedDelegate StartingConnectionChanged;
        internal void RaiseStartingConnectionChanged ()
        {
            if (this.StartingConnectionChanged != null) this.StartingConnectionChanged (this);
        }

        public event ActiveCallStateChangedDelegate ConnectedChanged;
        internal void RaiseConnectedChanged ()
        {
            if (this.ConnectedChanged != null) this.ConnectedChanged (this);
        }
        #endregion
    }
}

ActiveCall 에는 호출 상태를 정의하는 여러 속성과 호출 상태가 변경될 때 발생할 수 있는 두 개의 이벤트가 있습니다. 예제일 뿐이므로 호출 시작, 응답 및 종료를 시뮬레이션하는 데 사용되는 세 가지 메서드가 있습니다.

StartCallRequest 클래스

정적 클래스는 StartCallRequest 나가는 호출로 작업할 때 사용할 몇 가지 도우미 메서드를 제공합니다.

using System;
using Foundation;
using Intents;

namespace MonkeyCall
{
    public static class StartCallRequest
    {
        public static string URLScheme {
            get { return "monkeycall"; }
        }

        public static string ActivityType {
            get { return INIntentIdentifier.StartAudioCall.GetConstant ().ToString (); }
        }

        public static string CallHandleFromURL (NSUrl url)
        {
            // Is this a MonkeyCall handle?
            if (url.Scheme == URLScheme) {
                // Yes, return host
                return url.Host;
            } else {
                // Not handled
                return null;
            }
        }

        public static string CallHandleFromActivity (NSUserActivity activity)
        {
            // Is this a start call activity?
            if (activity.ActivityType == ActivityType) {
                // Yes, trap any errors
                try {
                    // Get first contact
                    var interaction = activity.GetInteraction ();
                    var startAudioCallIntent = interaction.Intent as INStartAudioCallIntent;
                    var contact = startAudioCallIntent.Contacts [0];

                    // Get the person handle
                    return contact.PersonHandle.Value;
                } catch {
                    // Error, report null
                    return null;
                }
            } else {
                // Not handled
                return null;
            }
        }
    }
}

CallHandleFromActivity 클래스는 CallHandleFromURL AppDelegate에서 발신 호출에서 호출되는 사람의 연락처 핸들을 가져오는 데 사용됩니다. 자세한 내용은 아래의 발신 통화 처리 섹션을 참조하세요.

ActiveCallManager 클래스

클래스는 ActiveCallManager MonkeyCall 앱에서 열려 있는 모든 호출을 처리합니다.

using System;
using System.Collections.Generic;
using Foundation;
using CallKit;

namespace MonkeyCall
{
    public class ActiveCallManager
    {
        #region Private Variables
        private CXCallController CallController = new CXCallController ();
        #endregion

        #region Computed Properties
        public List<ActiveCall> Calls { get; set; }
        #endregion

        #region Constructors
        public ActiveCallManager ()
        {
            // Initialize
            this.Calls = new List<ActiveCall> ();
        }
        #endregion

        #region Private Methods
        private void SendTransactionRequest (CXTransaction transaction)
        {
            // Send request to call controller
            CallController.RequestTransaction (transaction, (error) => {
                // Was there an error?
                if (error == null) {
                    // No, report success
                    Console.WriteLine ("Transaction request sent successfully.");
                } else {
                    // Yes, report error
                    Console.WriteLine ("Error requesting transaction: {0}", error);
                }
            });
        }
        #endregion

        #region Public Methods
        public ActiveCall FindCall (NSUuid uuid)
        {
            // Scan for requested call
            foreach (ActiveCall call in Calls) {
                if (call.UUID.Equals(uuid)) return call;
            }

            // Not found
            return null;
        }

        public void StartCall (string contact)
        {
            // Build call action
            var handle = new CXHandle (CXHandleType.Generic, contact);
            var startCallAction = new CXStartCallAction (new NSUuid (), handle);

            // Create transaction
            var transaction = new CXTransaction (startCallAction);

            // Inform system of call request
            SendTransactionRequest (transaction);
        }

        public void EndCall (ActiveCall call)
        {
            // Build action
            var endCallAction = new CXEndCallAction (call.UUID);

            // Create transaction
            var transaction = new CXTransaction (endCallAction);

            // Inform system of call request
            SendTransactionRequest (transaction);
        }

        public void PlaceCallOnHold (ActiveCall call)
        {
            // Build action
            var holdCallAction = new CXSetHeldCallAction (call.UUID, true);

            // Create transaction
            var transaction = new CXTransaction (holdCallAction);

            // Inform system of call request
            SendTransactionRequest (transaction);
        }

        public void RemoveCallFromOnHold (ActiveCall call)
        {
            // Build action
            var holdCallAction = new CXSetHeldCallAction (call.UUID, false);

            // Create transaction
            var transaction = new CXTransaction (holdCallAction);

            // Inform system of call request
            SendTransactionRequest (transaction);
        }
        #endregion
    }
}

다시 말하지만, 이것은 시뮬레이션에만 해당 ActiveCallManager 하므로 유일한 기본 개체 컬렉션을 ActiveCall 사용하고 해당 속성에서 지정된 호출을 찾는 루틴을 UUID 가집니다. 또한 발신 호출의 보류 상태를 시작, 종료 및 변경하는 메서드도 포함됩니다. 자세한 내용은 아래의 발신 통화 처리 섹션을 참조하세요.

ProviderDelegate 클래스

위에서 설명한 것처럼 대 CXProvider 역 외 알림을 위해 앱과 시스템 간에 양방향 통신을 제공합니다. 개발자는 사용자 지정 CXProviderDelegate 을 제공하고 앱에 CXProvider 연결하여 대역 외 CallKit 이벤트를 처리해야 합니다. MonkeyCall은 다음을 사용합니다.CXProviderDelegate

using System;
using Foundation;
using CallKit;
using UIKit;

namespace MonkeyCall
{
    public class ProviderDelegate : CXProviderDelegate
    {
        #region Computed Properties
        public ActiveCallManager CallManager { get; set;}
        public CXProviderConfiguration Configuration { get; set; }
        public CXProvider Provider { get; set; }
        #endregion

        #region Constructors
        public ProviderDelegate (ActiveCallManager callManager)
        {
            // Save connection to call manager
            CallManager = callManager;

            // Define handle types
            var handleTypes = new [] { (NSNumber)(int)CXHandleType.PhoneNumber };

            // Get Image Template
            var templateImage = UIImage.FromFile ("telephone_receiver.png");

            // Setup the initial configurations
            Configuration = new CXProviderConfiguration ("MonkeyCall") {
                MaximumCallsPerCallGroup = 1,
                SupportedHandleTypes = new NSSet<NSNumber> (handleTypes),
                IconTemplateImageData = templateImage.AsPNG(),
                RingtoneSound = "musicloop01.wav"
            };

            // Create a new provider
            Provider = new CXProvider (Configuration);

            // Attach this delegate
            Provider.SetDelegate (this, null);

        }
        #endregion

        #region Override Methods
        public override void DidReset (CXProvider provider)
        {
            // Remove all calls
            CallManager.Calls.Clear ();
        }

        public override void PerformStartCallAction (CXProvider provider, CXStartCallAction action)
        {
            // Create new call record
            var activeCall = new ActiveCall (action.CallUuid, action.CallHandle.Value, true);

            // Monitor state changes
            activeCall.StartingConnectionChanged += (call) => {
                if (call.isConnecting) {
                    // Inform system that the call is starting
                    Provider.ReportConnectingOutgoingCall (call.UUID, call.StartedConnectingOn.ToNSDate());
                }
            };

            activeCall.ConnectedChanged += (call) => {
                if (call.isConnected) {
                    // Inform system that the call has connected
                    provider.ReportConnectedOutgoingCall (call.UUID, call.ConnectedOn.ToNSDate ());
                }
            };

            // Start call
            activeCall.StartCall ((successful) => {
                // Was the call able to be started?
                if (successful) {
                    // Yes, inform the system
                    action.Fulfill ();

                    // Add call to manager
                    CallManager.Calls.Add (activeCall);
                } else {
                    // No, inform system
                    action.Fail ();
                }
            });
        }

        public override void PerformAnswerCallAction (CXProvider provider, CXAnswerCallAction action)
        {
            // Find requested call
            var call = CallManager.FindCall (action.CallUuid);

            // Found?
            if (call == null) {
                // No, inform system and exit
                action.Fail ();
                return;
            }

            // Attempt to answer call
            call.AnswerCall ((successful) => {
                // Was the call successfully answered?
                if (successful) {
                    // Yes, inform system
                    action.Fulfill ();
                } else {
                    // No, inform system
                    action.Fail ();
                }
            });
        }

        public override void PerformEndCallAction (CXProvider provider, CXEndCallAction action)
        {
            // Find requested call
            var call = CallManager.FindCall (action.CallUuid);

            // Found?
            if (call == null) {
                // No, inform system and exit
                action.Fail ();
                return;
            }

            // Attempt to answer call
            call.EndCall ((successful) => {
                // Was the call successfully answered?
                if (successful) {
                    // Remove call from manager's queue
                    CallManager.Calls.Remove (call);

                    // Yes, inform system
                    action.Fulfill ();
                } else {
                    // No, inform system
                    action.Fail ();
                }
            });
        }

        public override void PerformSetHeldCallAction (CXProvider provider, CXSetHeldCallAction action)
        {
            // Find requested call
            var call = CallManager.FindCall (action.CallUuid);

            // Found?
            if (call == null) {
                // No, inform system and exit
                action.Fail ();
                return;
            }

            // Update hold status
            call.isOnHold = action.OnHold;

            // Inform system of success
            action.Fulfill ();
        }

        public override void TimedOutPerformingAction (CXProvider provider, CXAction action)
        {
            // Inform user that the action has timed out
        }

        public override void DidActivateAudioSession (CXProvider provider, AVFoundation.AVAudioSession audioSession)
        {
            // Start the calls audio session here
        }

        public override void DidDeactivateAudioSession (CXProvider provider, AVFoundation.AVAudioSession audioSession)
        {
            // End the calls audio session and restart any non-call
            // related audio
        }
        #endregion

        #region Public Methods
        public void ReportIncomingCall (NSUuid uuid, string handle)
        {
            // Create update to describe the incoming call and caller
            var update = new CXCallUpdate ();
            update.RemoteHandle = new CXHandle (CXHandleType.Generic, handle);

            // Report incoming call to system
            Provider.ReportNewIncomingCall (uuid, update, (error) => {
                // Was the call accepted
                if (error == null) {
                    // Yes, report to call manager
                    CallManager.Calls.Add (new ActiveCall (uuid, handle, false));
                } else {
                    // Report error to user here
                    Console.WriteLine ("Error: {0}", error);
                }
            });
        }
        #endregion
    }
}

이 대리자의 인스턴스가 만들어지면 호출 작업을 처리하는 데 사용할 인스턴스가 전달 ActiveCallManager 됩니다. 다음으로 응답할 핸들 형식(CXHandleType)을 CXProvider 정의합니다.

// Define handle types
var handleTypes = new [] { (NSNumber)(int)CXHandleType.PhoneNumber };

또한 호출이 진행 중일 때 앱의 아이콘에 적용되는 템플릿 이미지를 가져옵니다.

// Get Image Template
var templateImage = UIImage.FromFile ("telephone_receiver.png");

이러한 값은 다음을 CXProviderConfiguration 구성하는 CXProvider데 사용되는 값에 번들로 제공됩니다.

// Setup the initial configurations
Configuration = new CXProviderConfiguration ("MonkeyCall") {
    MaximumCallsPerCallGroup = 1,
    SupportedHandleTypes = new NSSet<NSNumber> (handleTypes),
    IconTemplateImageData = templateImage.AsPNG(),
    RingtoneSound = "musicloop01.wav"
};

그런 다음 대리자는 이러한 구성을 사용하여 새 CXProvider 구성을 만들고 자체 구성에 연결합니다.

// Create a new provider
Provider = new CXProvider (Configuration);

// Attach this delegate
Provider.SetDelegate (this, null);

CallKit을 사용하는 경우 앱은 더 이상 자체 오디오 세션을 만들고 처리하지 않으며, 대신 시스템에서 만들고 처리할 오디오 세션을 구성하고 사용해야 합니다.

실제 앱인 경우 메서드를 DidActivateAudioSession 사용하여 시스템에서 제공한 미리 구성된 AVAudioSession 호출을 시작합니다.

public override void DidActivateAudioSession (CXProvider provider, AVFoundation.AVAudioSession audioSession)
{
    // Start the call's audio session here...
}

또한 이 메서드를 DidDeactivateAudioSession 사용하여 시스템 제공 오디오 세션에 대한 연결을 종료하고 해제합니다.

public override void DidDeactivateAudioSession (CXProvider provider, AVFoundation.AVAudioSession audioSession)
{
    // End the calls audio session and restart any non-call
    // releated audio
}

코드의 나머지 부분은 다음 섹션에서 자세히 설명합니다.

AppDelegate 클래스

MonkeyCall은 AppDelegate를 사용하여 앱 전체에서 ActiveCallManager 사용되는 인스턴스를 CXProviderDelegate 보유합니다.

using Foundation;
using UIKit;
using Intents;
using System;

namespace MonkeyCall
{
    [Register ("AppDelegate")]
    public class AppDelegate : UIApplicationDelegate
    {
        #region Constructors
        public override UIWindow Window { get; set; }
        public ActiveCallManager CallManager { get; set; }
        public ProviderDelegate CallProviderDelegate { get; set; }
        #endregion

        #region Override Methods
        public override bool FinishedLaunching (UIApplication application, NSDictionary launchOptions)
        {
            // Initialize the call handlers
            CallManager = new ActiveCallManager ();
            CallProviderDelegate = new ProviderDelegate (CallManager);

            return true;
        }

        public override bool OpenUrl (UIApplication app, NSUrl url, NSDictionary options)
        {
            // Get handle from url
            var handle = StartCallRequest.CallHandleFromURL (url);

            // Found?
            if (handle == null) {
                // No, report to system
                Console.WriteLine ("Unable to get call handle from URL: {0}", url);
                return false;
            } else {
                // Yes, start call and inform system
                CallManager.StartCall (handle);
                return true;
            }
        }

        public override bool ContinueUserActivity (UIApplication application, NSUserActivity userActivity, UIApplicationRestorationHandler completionHandler)
        {
            var handle = StartCallRequest.CallHandleFromActivity (userActivity);

            // Found?
            if (handle == null) {
                // No, report to system
                Console.WriteLine ("Unable to get call handle from User Activity: {0}", userActivity);
                return false;
            } else {
                // Yes, start call and inform system
                CallManager.StartCall (handle);
                return true;
            }
        }

        ...
        #endregion
    }
}

ContinueUserActivityOpenUrl 발신 호출을 처리할 때 및 재정의 메서드가 사용됩니다. 자세한 내용은 아래의 발신 통화 처리 섹션을 참조하세요.

들어오는 호출 처리

다음과 같은 일반적인 수신 호출 워크플로 중에 들어오는 VOIP 호출을 통과할 수 있는 몇 가지 상태 및 프로세스가 있습니다.

  • 들어오는 호출이 있음을 사용자(및 시스템)에게 알릴 수 있습니다.
  • 사용자가 통화에 응답하려고 할 때 알림을 받고 다른 사용자와 통화를 초기화합니다.
  • 사용자가 현재 호출을 종료하려는 경우 시스템 및 통신 네트워크에 알릴 수 있습니다.

다음 섹션에서는 앱이 CallKit을 사용하여 수신 호출 워크플로를 처리하는 방법을 자세히 살펴보고, MonkeyCall VOIP 앱을 예로 사용합니다.

사용자에게 수신 전화 알리기

원격 사용자가 로컬 사용자와 VOIP 대화를 시작하면 다음이 발생합니다.

A remote user has started a VOIP conversation

  1. 앱은 해당 Communications Network에서 들어오는 VOIP 호출이 있다는 알림을 받습니다.
  2. 앱은 이 CXProvider 앱을 사용하여 호출을 알리는 시스템을 보냅니 CXCallUpdate 다.
  3. 시스템은 CallKit을 사용하여 시스템 UI, System Services 및 기타 VOIP 앱에 대한 호출을 게시합니다.

예를 들어 다음을 수행합니다 CXProviderDelegate.

public void ReportIncomingCall (NSUuid uuid, string handle)
{
    // Create update to describe the incoming call and caller
    var update = new CXCallUpdate ();
    update.RemoteHandle = new CXHandle (CXHandleType.Generic, handle);

    // Report incoming call to system
    Provider.ReportNewIncomingCall (uuid, update, (error) => {
        // Was the call accepted
        if (error == null) {
            // Yes, report to call manager
            CallManager.Calls.Add (new ActiveCall (uuid, handle, false));
        } else {
            // Report error to user here
            Console.WriteLine ("Error: {0}", error);
        }
    });
}

이 코드는 새 CXCallUpdate 인스턴스를 만들고 호출자를 식별하는 핸들을 연결합니다. 다음으로, 클래스의 메서드를 CXProvider 사용하여 ReportNewIncomingCall 호출을 시스템에 알릴 수 있습니다. 성공하면 앱의 활성 호출 컬렉션에 호출이 추가됩니다. 그렇지 않은 경우 사용자에게 오류를 보고해야 합니다.

수신 전화에 응답하는 사용자

사용자가 들어오는 VOIP 호출에 응답하려는 경우 다음이 발생합니다.

The user answers the incoming VOIP call

  1. 시스템 UI는 사용자가 VOIP 호출에 응답하려고 했음을 시스템에 알릴 수 있습니다.
  2. 시스템은 응답 의도를 알리는 앱을 CXProvider 보냅니 CXAnswerCallAction 다.
  3. 앱은 사용자가 통화에 응답하고 있으며 VOIP 호출이 평소와 같이 진행된다는 것을 Communication Network에 알릴 수 있습니다.

예를 들어 다음을 수행합니다 CXProviderDelegate.

public override void PerformAnswerCallAction (CXProvider provider, CXAnswerCallAction action)
{
    // Find requested call
    var call = CallManager.FindCall (action.CallUuid);

    // Found?
    if (call == null) {
        // No, inform system and exit
        action.Fail ();
        return;
    }

    // Attempt to answer call
    call.AnswerCall ((successful) => {
        // Was the call successfully answered?
        if (successful) {
            // Yes, inform system
            action.Fulfill ();
        } else {
            // No, inform system
            action.Fail ();
        }
    });
}

이 코드는 먼저 활성 호출 목록에서 지정된 호출을 검색합니다. 호출을 찾을 수 없으면 시스템에 알림이 표시되고 메서드가 종료됩니다. 이 메서드가 발견 AnswerCall 되면 호출을 ActiveCall 시작하기 위해 클래스의 메서드가 호출되고, 성공하거나 실패하면 시스템이 정보입니다.

수신 전화를 종료하는 사용자

사용자가 앱의 UI 내에서 호출을 종료하려는 경우 다음이 발생합니다.

The user terminates the call from within the app's UI

  1. 앱은 호출이 종료됨을 CXTransaction 알리기 위해 시스템에 전송되는 번들로 번들로 제공되는 것을 만듭니다CXEndCallAction.
  2. 시스템은 종료 호출 의도를 확인하고 을 통해 CXProvider앱에 다시 보냅니 CXEndCallAction 다.
  3. 그런 다음 앱은 통신 네트워크에 호출이 종료되고 있음을 알릴 수 있습니다.

예를 들어 다음을 수행합니다 CXProviderDelegate.

public override void PerformEndCallAction (CXProvider provider, CXEndCallAction action)
{
    // Find requested call
    var call = CallManager.FindCall (action.CallUuid);

    // Found?
    if (call == null) {
        // No, inform system and exit
        action.Fail ();
        return;
    }

    // Attempt to answer call
    call.EndCall ((successful) => {
        // Was the call successfully answered?
        if (successful) {
            // Remove call from manager's queue
            CallManager.Calls.Remove (call);

            // Yes, inform system
            action.Fulfill ();
        } else {
            // No, inform system
            action.Fail ();
        }
    });
}

이 코드는 먼저 활성 호출 목록에서 지정된 호출을 검색합니다. 호출을 찾을 수 없으면 시스템에 알림이 표시되고 메서드가 종료됩니다. 호출이 발견되면 호출을 EndCallActiveCall 종료하기 위해 클래스의 메서드가 호출되고 성공하거나 실패하면 시스템이 정보입니다. 성공하면 활성 호출 컬렉션에서 호출이 제거됩니다.

여러 호출 관리

대부분의 VOIP 앱은 한 번에 여러 호출을 처리할 수 있습니다. 예를 들어 현재 활성 VOIP 호출이 있고 앱이 새 수신 전화가 있다는 알림을 받는 경우 사용자는 첫 번째 호출에서 일시 중지하거나 전화를 끊어 두 번째 호출에 응답할 수 있습니다.

위의 상황에서 시스템은 여러 작업(예: 및 )의 목록을 포함하는 앱에 CXEndCallActionCXAnswerCallAction보냅니 CXTransaction 다. 시스템에서 UI를 적절하게 업데이트할 수 있도록 이러한 모든 작업을 개별적으로 수행해야 합니다.

나가는 호출 처리

사용자가 전화 앱의 최근 항목(예: 앱에 속한 호출)의 항목을 탭하면 시스템에서 시작 호출 의도보냅니다.

Receiving a Start Call Intent

  1. 앱은 시스템에서 받은 통화 시작 의도를 기반으로 호출 시작 작업을 만듭니다.
  2. 앱은 시스템에서 호출 시작 작업을 요청하는 데 사용합니다 CXCallController .
  3. 시스템에서 작업을 수락하면 대리자를 통해 앱에 XCProvider 반환됩니다.
  4. 앱은 Communication Network를 사용하여 발신 통화를 시작합니다.

의도에 대한 자세한 내용은 의도 및 의도 UI 확장 설명서를 참조하세요.

나가는 호출 수명 주기

CallKit 및 발신 호출로 작업할 때 앱은 시스템에 다음 수명 주기 이벤트를 알려야 합니다.

  1. 시작 - 발신 호출이 시작되도록 시스템에 알릴 수 있습니다.
  2. 시작 - 발신 호출이 시작되었음을 시스템에 알릴 수 있습니다.
  3. 커넥트 - 보내는 호출이 연결 중임을 시스템에 알릴 수 있습니다.
  4. 커넥트 - 나가는 통화가 연결되었고 양측이 지금 대화할 수 있음을 알릴 수 있습니다.

예를 들어 다음 코드는 발신 호출을 시작합니다.

private CXCallController CallController = new CXCallController ();
...

private void SendTransactionRequest (CXTransaction transaction)
{
    // Send request to call controller
    CallController.RequestTransaction (transaction, (error) => {
        // Was there an error?
        if (error == null) {
            // No, report success
            Console.WriteLine ("Transaction request sent successfully.");
        } else {
            // Yes, report error
            Console.WriteLine ("Error requesting transaction: {0}", error);
        }
    });
}

public void StartCall (string contact)
{
    // Build call action
    var handle = new CXHandle (CXHandleType.Generic, contact);
    var startCallAction = new CXStartCallAction (new NSUuid (), handle);

    // Create transaction
    var transaction = new CXTransaction (startCallAction);

    // Inform system of call request
    SendTransactionRequest (transaction);
}

이 메서드는 클래스의 CXHandle 메서드를 사용하여 RequestTransaction 시스템에 전송되는 번들로 제공되는 CXTransaction a를 만들고 이를 사용하여 구성 CXStartCallActionCXCallController 합니다. 메서드를 RequestTransaction 호출하면 시스템은 새 호출이 시작되기 전에 원본(전화 앱, FaceTime, VOIP 등)에 관계없이 기존 호출을 보류 상태로 유지할 수 있습니다.

발신 VOIP 통화를 시작하라는 요청은 Siri, 연락처 카드 항목(연락처 앱) 또는 전화 앱의 최근 항목 목록과 같은 여러 소스에서 올 수 있습니다. 이러한 상황에서 앱은 A 내부에 시작 호출 의도를 NSUserActivity 보내고 AppDelegate는 이를 처리해야 합니다.

public override bool ContinueUserActivity (UIApplication application, NSUserActivity userActivity, UIApplicationRestorationHandler completionHandler)
{
    var handle = StartCallRequest.CallHandleFromActivity (userActivity);

    // Found?
    if (handle == null) {
        // No, report to system
        Console.WriteLine ("Unable to get call handle from User Activity: {0}", userActivity);
        return false;
    } else {
        // Yes, start call and inform system
        CallManager.StartCall (handle);
        return true;
    }
}

CallHandleFromActivity 여기서 도우미 클래스 StartCallRequest 의 메서드는 호출되는 사람에게 핸들을 가져오는 데 사용됩니다(위의 StartCallRequest 클래스 참조).

PerformStartCallAction ProviderDelegate 클래스메서드는 최종적으로 실제 발신 호출을 시작하고 시스템에 해당 수명 주기를 알리는 데 사용됩니다.

public override void PerformStartCallAction (CXProvider provider, CXStartCallAction action)
{
    // Create new call record
    var activeCall = new ActiveCall (action.CallUuid, action.CallHandle.Value, true);

    // Monitor state changes
    activeCall.StartingConnectionChanged += (call) => {
        if (call.IsConnecting) {
            // Inform system that the call is starting
            Provider.ReportConnectingOutgoingCall (call.UUID, call.StartedConnectingOn.ToNSDate());
        }
    };

    activeCall.ConnectedChanged += (call) => {
        if (call.IsConnected) {
            // Inform system that the call has connected
            Provider.ReportConnectedOutgoingCall (call.UUID, call.ConnectedOn.ToNSDate ());
        }
    };

    // Start call
    activeCall.StartCall ((successful) => {
        // Was the call able to be started?
        if (successful) {
            // Yes, inform the system
            action.Fulfill ();

            // Add call to manager
            CallManager.Calls.Add (activeCall);
        } else {
            // No, inform system
            action.Fail ();
        }
    });
}

클래스의 ActiveCall 인스턴스를 만들고(진행 중인 호출에 대한 정보를 보유하기 위해) 호출되는 사용자로 채웁니다. StartingConnectionChangedConnectedChanged 이벤트는 나가는 호출 수명 주기를 모니터링하고 보고하는 데 사용됩니다. 호출이 시작되고 시스템에서 작업이 수행되었음을 알렸습니다.

발신 통화 종료

사용자가 발신 호출을 완료하고 종료하려는 경우 다음 코드를 사용할 수 있습니다.

private CXCallController CallController = new CXCallController ();
...

private void SendTransactionRequest (CXTransaction transaction)
{
    // Send request to call controller
    CallController.RequestTransaction (transaction, (error) => {
        // Was there an error?
        if (error == null) {
            // No, report success
            Console.WriteLine ("Transaction request sent successfully.");
        } else {
            // Yes, report error
            Console.WriteLine ("Error requesting transaction: {0}", error);
        }
    });
}

public void EndCall (ActiveCall call)
{
    // Build action
    var endCallAction = new CXEndCallAction (call.UUID);

    // Create transaction
    var transaction = new CXTransaction (endCallAction);

    // Inform system of call request
    SendTransactionRequest (transaction);
}

종료할 호출의 UUID를 사용하여 만드는 경우 클래스의 메서드를 CXTransaction 사용하여 System으로 전송되는 호출에 RequestTransaction 번들로 묶습니다CXEndCallAction.CXCallController

추가 CallKit 세부 정보

이 섹션에서는 다음과 같이 CallKit을 사용할 때 개발자가 고려해야 하는 몇 가지 추가 세부 정보를 설명합니다.

  • 공급자 구성
  • 작업 오류
  • 시스템 제한 사항
  • VOIP 오디오

공급자 구성

공급자 구성을 사용하면 iOS 10 VOIP 앱이 CallKit로 작업할 때 사용자 환경(네이티브 In-Call UI 내)을 사용자 지정할 수 있습니다.

앱은 다음과 같은 유형의 사용자 지정을 수행할 수 있습니다.

  • 지역화된 이름을 표시합니다.
  • 화상 통화 지원을 사용하도록 설정합니다.
  • 자체 템플릿 이미지 아이콘을 표시하여 통화 내 UI의 단추를 사용자 지정합니다. 사용자 지정 단추와의 사용자 상호 작용은 처리할 앱으로 직접 전송됩니다.

작업 오류

CallKit을 사용하는 iOS 10 VOIP 앱은 정상적으로 실패한 작업을 처리하고 사용자에게 항상 작업 상태를 알려야 합니다.

다음 예제를 고려합니다.

  1. 앱이 통화 시작 작업을 수신했으며 Communication Network를 사용하여 새 VOIP 호출을 초기화하는 프로세스를 시작했습니다.
  2. 네트워크 통신 기능이 제한되거나 없으므로 이 연결이 실패합니다.
  3. 은 장애를 시스템에 알리기 위해 호출 시작 작업(Action.Fail())에 장애 메시지를 다시 보내야 합니다.
  4. 이를 통해 시스템은 사용자에게 통화 상태 알릴 수 있습니다. 예를 들어 호출 실패 UI를 표시합니다.

또한 iOS 10 VOIP 앱은 지정된 시간 내에 예상 작업을 처리할 수 없을 때 발생할 수 있는 시간 제한 오류에 응답해야 합니다. CallKit에서 제공하는 각 작업 유형에는 연결된 최대 시간 제한 값이 있습니다. 이러한 시간 제한 값은 사용자가 요청한 모든 CallKit 작업이 반응형 방식으로 처리되므로 OS 유동적이고 응답성이 유지됩니다.

공급자 대리자(CXProviderDelegate)에는 이 시간 제한 상황도 정상적으로 처리하도록 재정의해야 하는 몇 가지 메서드가 있습니다.

시스템 제한 사항

iOS 10 VOIP 앱을 실행하는 iOS 디바이스의 현재 상태에 따라 특정 시스템 제한이 적용될 수 있습니다.

예를 들어 다음과 같은 경우 시스템에서 들어오는 VOIP 호출을 제한할 수 있습니다.

  1. 호출하는 사람이 사용자의 차단된 호출자 목록에 있습니다.
  2. 사용자의 iOS 디바이스가 방해 금지 모드에 있습니다.

이러한 상황에서 VOIP 호출이 제한되는 경우 다음 코드를 사용하여 처리합니다.

public class ProviderDelegate : CXProviderDelegate
{
...

    public void ReportIncomingCall (NSUuid uuid, string handle)
    {
        // Create update to describe the incoming call and caller
        var update = new CXCallUpdate ();
        update.RemoteHandle = new CXHandle (CXHandleType.Generic, handle);

        // Report incoming call to system
        Provider.ReportNewIncomingCall (uuid, update, (error) => {
            // Was the call accepted
            if (error == null) {
                // Yes, report to call manager
                CallManager.Calls.Add (new ActiveCall (uuid, handle, false));
            } else {
                // Report error to user here
                if (error.Code == (int)CXErrorCodeIncomingCallError.CallUuidAlreadyExists) {
                    // Handle duplicate call ID
                } else if (error.Code == (int)CXErrorCodeIncomingCallError.FilteredByBlockList) {
                    // Handle call from blocked user
                } else if (error.Code == (int)CXErrorCodeIncomingCallError.FilteredByDoNotDisturb) {
                    // Handle call while in do-not-disturb mode
                } else {
                    // Handle unknown error
                }
            }
        });
    }

}

VOIP 오디오

CallKit은 라이브 VOIP 호출 중에 iOS 10 VOIP 앱에 필요한 오디오 리소스를 처리하는 데 몇 가지 이점을 제공합니다. 가장 큰 이점 중 하나는 iOS 10에서 실행할 때 앱의 오디오 세션에 높은 우선 순위가 있다는 것입니다. 이는 기본 제공 전화 및 FaceTime 앱과 동일한 우선 순위 수준이며, 이 향상된 우선 순위 수준은 실행 중인 다른 앱이 VOIP 앱의 오디오 세션을 중단하지 못하도록 합니다.

또한 CallKit는 성능을 향상시키고 사용자 기본 설정 및 디바이스 상태에 따라 라이브 통화 중에 VOIP 오디오를 특정 출력 디바이스로 지능적으로 라우팅할 수 있는 다른 오디오 라우팅 힌트에 액세스할 수 있습니다. 예를 들어 Bluetooth 헤드폰, 라이브 CarPlay 연결 또는 접근성 설정과 같은 연결된 디바이스를 기반으로 합니다.

CallKit을 사용하는 일반적인 VOIP 호출의 수명 주기 동안 앱은 CallKit에서 제공하는 오디오 스트림을 구성해야 합니다. 다음 예제를 살펴보세요.

The Start Call Action Sequence

  1. 수신 전화에 응답하기 위해 앱에서 통화 시작 동작을 받습니다.
  2. 앱에서 이 작업을 수행하기 전에 해당 작업에 필요한 구성을 AVAudioSession제공합니다.
  3. 앱은 작업이 수행되었음을 시스템에 알릴 수 있습니다.
  4. 호출이 연결되기 전에 CallKit은 앱이 요청한 구성과 일치하는 우선 순위 AVAudioSession 가 높은 구성을 제공합니다. 앱은 해당 메서드를 DidActivateAudioSession 통해 알림을 받습니다 CXProviderDelegate.

호출 디렉터리 확장 작업

CallKit 을 사용할 때 통화 디렉터리 확장 은 차단된 통화 번호를 추가하고 iOS 디바이스의 연락처 앱에 있는 연락처에 지정된 VOIP 앱과 관련된 번호를 식별하는 방법을 제공합니다.

통화 디렉터리 확장 구현

Xamarin.iOS 앱에서 호출 디렉터리 확장을 구현하려면 다음을 수행합니다.

  1. Mac용 Visual Studio 앱의 솔루션을 엽니다.

  2. 솔루션 탐색기 솔루션 이름을 마우스 오른쪽 단추로 클릭하고 새 프로젝트 추가>를 선택합니다.

  3. iOS>확장>호출 디렉터리 확장을 선택하고 다음 단추를 클릭합니다.

    Creating a new Call Directory Extension

  4. 확장의 이름을 입력하고 다음 단추를 클릭합니다.

    Entering a name for the extension

  5. 필요한 경우 프로젝트 이름 및/또는 솔루션 이름을 조정하고 만들기 단추를 클릭합니다.

    Creating the project

그러면 다음과 같은 클래스가 프로젝트에 추가 CallDirectoryHandler.cs 됩니다.

using System;

using Foundation;
using CallKit;

namespace MonkeyCallDirExtension
{
    [Register ("CallDirectoryHandler")]
    public class CallDirectoryHandler : CXCallDirectoryProvider, ICXCallDirectoryExtensionContextDelegate
    {
        #region Constructors
        protected CallDirectoryHandler (IntPtr handle) : base (handle)
        {
            // Note: this .ctor should not contain any initialization logic.
        }
        #endregion

        #region Override Methods
        public override void BeginRequest (CXCallDirectoryExtensionContext context)
        {
            context.Delegate = this;

            if (!AddBlockingPhoneNumbers (context)) {
                Console.WriteLine ("Unable to add blocking phone numbers");
                var error = new NSError (new NSString ("CallDirectoryHandler"), 1, null);
                context.CancelRequest (error);
                return;
            }

            if (!AddIdentificationPhoneNumbers (context)) {
                Console.WriteLine ("Unable to add identification phone numbers");
                var error = new NSError (new NSString ("CallDirectoryHandler"), 2, null);
                context.CancelRequest (error);
                return;
            }

            context.CompleteRequest (null);
        }
        #endregion

        #region Private Methods
        private bool AddBlockingPhoneNumbers (CXCallDirectoryExtensionContext context)
        {
            // Retrieve phone numbers to block from data store. For optimal performance and memory usage when there are many phone numbers,
            // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded.
            //
            // Numbers must be provided in numerically ascending order.

            long [] phoneNumbers = { 14085555555, 18005555555 };

            foreach (var phoneNumber in phoneNumbers)
                context.AddBlockingEntry (phoneNumber);

            return true;
        }

        private bool AddIdentificationPhoneNumbers (CXCallDirectoryExtensionContext context)
        {
            // Retrieve phone numbers to identify and their identification labels from data store. For optimal performance and memory usage when there are many phone numbers,
            // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded.
            //
            // Numbers must be provided in numerically ascending order.

            long [] phoneNumbers = { 18775555555, 18885555555 };
            string [] labels = { "Telemarketer", "Local business" };

            for (var i = 0; i < phoneNumbers.Length; i++) {
                long phoneNumber = phoneNumbers [i];
                string label = labels [i];
                context.AddIdentificationEntry (phoneNumber, label);
            }

            return true;
        }
        #endregion

        #region Public Methods
        public void RequestFailed (CXCallDirectoryExtensionContext extensionContext, NSError error)
        {
            // An error occurred while adding blocking or identification entries, check the NSError for details.
            // For Call Directory error codes, see the CXErrorCodeCallDirectoryManagerError enum.
            //
            // This may be used to store the error details in a location accessible by the extension's containing app, so that the
            // app may be notified about errors which occurred while loading data even if the request to load data was initiated by
            // the user in Settings instead of via the app itself.
        }
        #endregion
    }
}

BeginRequest 필요한 기능을 제공하려면 호출 디렉터리 처리기의 메서드를 수정해야 합니다. 위의 샘플의 경우 VOIP 앱의 연락처 데이터베이스에서 차단 및 사용 가능한 숫자 목록을 설정하려고 시도합니다. 어떤 이유로든 요청이 실패하는 경우 실패를 설명하는 요청을 만들고 NSError 클래스의 메서드를 CancelRequestCXCallDirectoryExtensionContext 전달합니다.

차단된 숫자를 설정하려면 클래스의 AddBlockingEntry 메서드를 CXCallDirectoryExtensionContext 사용합니다. 메서드 에 제공된 숫자는 숫자 오름차순이어야 합니다 . 전화 번호가 많은 경우 최적의 성능 및 메모리 사용량을 위해 지정된 시간에 숫자 하위 집합만 로드하고 자동 릴리스 풀을 사용하여 로드되는 각 숫자 일괄 처리 중에 할당된 개체를 해제하는 것이 좋습니다.

VOIP 앱에 알려진 연락처 번호를 연락처 앱에 알리려면 클래스의 CXCallDirectoryExtensionContext 메서드를 사용하고 AddIdentificationEntry 숫자와 식별 레이블을 모두 제공합니다. 다시 말하지만 메서드 에 제공된 숫자는 숫자 오름차순이어야 합니다 . 전화 번호가 많은 경우 최적의 성능 및 메모리 사용량을 위해 지정된 시간에 숫자 하위 집합만 로드하고 자동 릴리스 풀을 사용하여 로드되는 각 숫자 일괄 처리 중에 할당된 개체를 해제하는 것이 좋습니다.

요약

이 문서에서는 Apple이 iOS 10에서 릴리스한 새로운 CallKit API와 Xamarin.iOS VOIP 앱에서 구현하는 방법을 설명했습니다. CallKit을 통해 앱이 iOS 시스템에 통합되는 방법, 기본 제공 앱(예: 전화)과 기능 패리티를 제공하는 방법, Lock 및 Home Screens과 같은 위치에서 Siri 상호 작용 및 연락처 앱을 통해 iOS 전체에서 앱의 가시성을 높이는 방법을 보여 줍니다.