블로그 이미지
ssun++

카테고리

[전체] (73)
Android (7)
JavaScript (9)
CI (5)
Language (14)
ETC (38)
Total319,148
Today1
Yesterday16

[참고문서]

http://docs.sencha.com/touch/2-0/#!/guide/views



[Using Views in your Applications]

사용자의 관점에서 Application은 단순히 View의 집합으로 볼 수 있습니다. Model과 Controller도 많은 역할을 하지만, View는 사용자와 직접 인터렉션합니다. 이 가이드에서는 View를 생성하여 Application 생성하는 방법을 살펴 볼 것입니다.



[Using Existing Components]

View를 생성하는 가장 쉬운 방법은 이미 존재하는 Component로 Ext.create를 사용하는 것입니다. 예를 들어, 내부에 HTML이 포함된 간단한 Panel을 생성하려면 아래와 같이 할 수 있습니다.

Ext.create('Ext.Panel', {

    html: 'Welcome to my app',

    fullscreen: true

});

이 소스는 HTML이 포함된 Panel을 생성하여 화면에 fit 시킵니다. 이러한 방식으로 기존의 Component를 생성할 수도 있지만, 가장 좋은 방법은 서브클래스를 작성하여 생성하는 것입니다.

Ext.define('MyApp.view.Welcome', {

    extend: 'Ext.Panel',


    config: {

        html: 'Welcome to my app',

        fullscreen: true

    }

});


Ext.create('MyApp.view.Welcome');

결과는 동일하지만, 어느때나 생성할 수 있는 새로운 Component를 가지게 되어습니다. 앱을 생성하는데 있어서 이러한 방법이 권장됩니다 - 기존 Component의 서브클래스를 작성하여 그 인스턴스를 생성합니다. 소스에서 바뀐 부분을 살펴봅시다.

  • Ext.define으로 기존 클래스를 상속하여 새로운 클래스를 생성할 수 있습니다. (이경우 Ext.Panel) 
  • 새로운 View 클래스는 MyApp.view.MyView와 같은 형식을 따릅니다. 물론 자유로운 네이밍이 가능하지만 규약을 따르기를 권장합니다.
  • 새로운 클래스의 config 객체 내부에 config 설정이 가능합니다.

Ext.Panel에서 가능한 config 옵션은 새로운 클래스의 config 블럭에서 명시하거나 새로운 객체 생성 시 명시 가능합니다. 서브클래스를 정의할 때 config 객체를 사용하도록 합니다.

예를 들어, Ext.create 호출 시 config만 추가한 코드를 봅시다.

Ext.define('MyApp.view.Welcome', {

    extend: 'Ext.Panel',


    config: {

        html: 'Welcome to my app',

        fullscreen: true

    }

});


Ext.create('MyApp.view.Welcome', {

    styleHtmlContent: true

});



[A Real World Example]

우리 트위터 앱의 View 클래스 입니다.

Ext.define('Twitter.view.SearchBar', {

    extend: 'Ext.Toolbar',

    xtype : 'searchbar',

    requires: ['Ext.field.Search'],


    config: {

        ui: 'searchbar',

        layout: 'vbox',

        cls: 'big',


        items: [

            {

                xtype: 'title',

                title: 'Twitter Search'

            },

            {

                xtype: 'searchfield',

                placeHolder: 'Search...'

            }

        ]

    }

});

이전과 동일한 패턴을 따릅니다 - Twitter.view.SearchBar라는 클래스를 생성하였고, Ext.Toolbar 클래스를 상속합니다. layout이나 items와 같은 Config 옵션을 넣어주었습니다.

이번에 두가지 새로운 옵션을 사용했습니다.

  • requires - items 배열에서 searchfield를 사용하고 있기 때문에, View에 Ext.field.Search 클래스가 필요함을 알려야 합니다. 동적 로딩 시스템이 xtype으로 명시한 클래스를 찾지 못할 때 의존성을 직접 정의해야 합니다.
  • xtype - 새로운 클래스에 xtype을 명시하면 config 객체를 통해서 쉽게 생성할 수 있습니다. (위에서 searchfield 생성하듯이)

이를 통해서 새로운 View의 인스턴스를 2가지 방법으로 생성할 수 있습니다.

//creates a standalone instance

Ext.create('Twitter.view.SearchBar');


//alternatively, use xtype to create our new class inside a Panel

Ext.create('Ext.Panel', {

    html: 'Welcome to my app',


    items: [

        {

            xtype: 'searchbar',

            docked: 'top'

        }

    ]

});



[Custom Configurations and Behavior]

Sencha Touch 2는 config 시스템을 활용하여 예측가능한 API를 만들고, 코드를 깔끔하고 테스트하기 쉽게 합니다. 직접 작성하는 클래스에서도 동일한 방식을 따르기를 권장합니다.

탭할 때 이미지에 대한 정보를 팝업으로 보여주는 이미지 뷰어를 생성하려고 한다고 해봅시다. 우리의 목적은 이미지 url, 타이틀, 설명, 탭했을 때 보여줄 타이틀, 설명을 설정할 수 있는 재사용 가능한 View를 생성하는 것입니다.

이미지 출력 관련된 작업은 Ext.Img 컴포넌트를 사용하여 할 수 있습니다.

Ext.define('MyApp.view.Image', {

    extend: 'Ext.Img',


    config: {

        title: null,

        description: null

    },


    //sets up our tap event listener

    initialize: function() {

        this.callParent(arguments);


        this.element.on('tap', this.onTap, this);

    },


    //this is called whenever you tap on the image

    onTap: function() {

        Ext.Msg.alert(this.getTitle(), this.getDescription());

    }

});


//creates a full screen tappable image

Ext.create('MyApp.view.Image', {

    title: 'Orion Nebula',

    description: 'The Orion Nebula is rather pretty',


    src: 'http://apod.nasa.gov/apod/image/1202/oriondeep_andreo_960.jpg',

    fullscreen: true

});

두가지 config를 추가하였습니다 - title과 description - 둘 다 null로 시작합니다. 새로운 클래스에 대한 객체를 생성할 때 다른 config를 설정하듯이 title과 description을 설정합니다.

새로운 동작은 initialize와 onTap 함수에서 발생합니다. initialize 함수는 컴포넌트가 객체화 될 때 호출되기때문에, 이벤트 리스너 설정과 같은 설정을 하기에 좋은 위치입니다. 가장 먼저 한 것은 this.callParent(arguments)를 호출하여 상위 클래서의 initialize 함수를 호출 한 것입니다. 이것은 매우 중요하며, 이 라인을 생략할 경우 컴포넌트가 정상적으로 동작하지 않을 수 있습니다.

callParent 이후, 탭 리스너를 컴포넌트의 엘리먼트에 추가하였습니다. 엘리먼트가 탭 될 때 onTap 함수가 호출 될 것입니다. Sencha Touch 2의 모든 컴포넌트는 element 속성을 가지고 있습니다. DOM 오브젝트에서 이벤트 감지, 스타일 적용 등 Ext.dom.Element에 어떠한 동작을 하기 위해서 element 속성을 사용할 수 있습니다.

onTap 함수 자체는 아주 단순합니다. Ext.Msg.alert를 사용하여 이미지에 대한 정보를 팝업으로 보여줄 뿐입니다. 새로운 config 2개를 주목합시다 - title 과 description - 둘 다 getter 함수(getTitle, getDescription)와 setter 함수(setTitle, setDescription)를 갖게 됩니다.



[Advanced Configurations]

클래스에 새로운 config를 생성할 때, getter와 setter 함수가 생성됩니다. 'border'라는 config에 대해서 자동으로 getBorder와 setBorder 함수가 생성됩니다.

Ext.define('MyApp.view.MyView', {

    extend: 'Ext.Panel',


    config: {

        border: 10

    }

});


var view = Ext.create('MyApp.view.MyView');


alert(view.getBorder()); //alerts 10


view.setBorder(15);

alert(view.getBorder()); //now alerts 15

getter와 setter만 생성되는 것은 아니며, 컴포넌트 작성자의 삶을 단순하게 해주는 함수가 더 있습니다 - applyBorder와 updateBorder입니다.

Ext.define('MyApp.view.MyView', {

    extend: 'Ext.Panel',


    config: {

        border: 0

    },


    applyBorder: function(value) {

        return value + "px solid red";

    },


    updateBorder: function(newValue, oldValue) {

        this.element.setStyle('border', newValue);

    }

});

applyBorder는 컴포넌트가 객체화 될 때를 포함하여, border가 설정되거나 변경될 때 내부적으로 호출됩니다. 입력값을 변형하기 위한 코드가 위치하기 좋은 위치입니다. 이 경우 border width를 전달받아 CSS 문자열로 리턴합니다.

View의 border를 10으로 set 할 때, applyBorder 함수는 '10px solid red'로 값을 변형합니다. apply 함수는 전적으로 옵션이지만 반드시 값을 리턴해야하며 그렇지 않을 때는 아무 동작도 발생하지 않을 것입니다.

updateBorder 함수는 applyBorder 함수가 값을 변형한 뒤에 호출되며, DOM을 수정하거나 AJAX 요청을 보내는 등의 동작을 하는데 사용됩니다. 위의 경우에는 setStyle을 사용하여 View 엘리먼트의 border 스타일을 업데이트 하였습니다. setBorder가 호출될 때 DOM이 새로운 스타일을 즉시 적용하게 됩니다.



[Using in MVC]

코드가 잘 구성되어 재사용성을 높이기 위해 Sencha Touch 앱이 MVC 규약을 따르기를 권장합니다. MVC에서 "V"로써, View 또한 이런 구조에 적합합니다. View에 관련된 규약은 매우 간단하며 위에서 사용한 것과 같은 네이밍 규약을 따릅니다.

MyApp.view.MyView 클래스는 app/view/MyView.js 파일 내부에 정의되어야 합니다 - 이것은 Application이 클래스를 자동으로 찾아서 로딩할 수 있도록 합니다. Sencha Touch 앱의 MVC 기반 파일 구조에 익숙하지 않아도 이것은 아주 단순합니다 - 앱은 단순히 html 파일, app.js 그리고 Model, View, Controller의 집합입니다.

index.html

app.js

app/

    controller/

    model/

    view/

        MyView.js

View는 원하는 만큼 생성하여 app/view 디렉토리에 넣을 수 있습니다. app.js에 Application View를 명시함으로써 자동으로 로드되게 할 수 있습니다.

//contents of app.js

Ext.application({

    name: 'MyApp',

    views: ['MyView'],


    launch: function() {

        Ext.create('MyApp.view.MyView');

    }

});

간단한 네이밍 규칙을 따름으로써 View 클래스를 쉽게 로드하고 객체를 생성할 수 있습니다. 위의 예제는 정확히 그렇습니다 - MyView 클래스를 로드하여 launch 함수 내부에서 객체를 생성합니다. Sencha Touch에서 MVC 앱 작성에 대해서 더 찾아보기 위해서는 intro to apps guide를 참고하기 바랍니다.

Posted by ssun++

댓글을 달아 주세요

  1. 2012.12.03 22:27  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

  2. 2013.01.29 14:07  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

[참고문서]

http://docs.sencha.com/touch/2-0/#!/guide/controllers



[Controllers]

Controller는 앱 내부에서 발생하는 이벤트 처리를 담당합니다. 앱에 사용자가 탭 할 수 있는 로그아웃 버튼이 있다면, Controller는 버튼의 탭 이벤트를 감지하여 적절한 액션을 취할 것입니다. 이는 View 클래스로 하여금 데이터의 출력을 처리하고, Model 클래스는 데이터의 로드와 저장을 처리할 수 있게 합니다 - Controller는 View와 Model을 결합하는 역할을 합니다.



[Relation to Ext.app.Application]

Controller는 Application의 컨텍스트 내부에 존재합니다. Controller는 앱의 일부를 핸들링하고, Appllication은 여러개의 Controller로 구성됩니다. 예를 들어, 온라인 쇼핑몰의 주문을 처리하는 Application이라면 주문, 고객, 상품에 대한 Controller를 가질 것입니다.

Application이 사용하는 모든 Controller는 Application의 Ext.app.Application.controllers 설정에 명시됩니다. Application은 자동으로 Controller를 객체화하고 각각에 대한 레퍼런스를 유지하여, 보통은 Controller를 직접 객체화 할 필요가 없습니다. 관습적으로 Controller는 주로 처리하는 것(보통 Model)의 이름을 복수 형태로 따릅니다 - 예를 들어, 'MyApp'이라는 앱에 상품을 관리하는 Controller가 있다면, app/controller/Products.js 파일에 MyApp.controller.Products 클래스를 생성하는 것이 일반적입니다.



[Launching]

Application의 실행 프로세스에 4단계가 있고, 그중 2단계는 Controller에 있습니다. 먼저, Controller는 init 함수를 정의할 수 있으며, Application의 launch 함수 이전에 호출됩니다. 다음으로, Application과 Profile의 launch 함수가 호출된 후, Controller의 launch 함수가 마지막 단계로 호출됩니다.

  1. Controller#init 함수 호출
  2. Profile#launch 함수 호출
  3. Application#launch 함수 호출
  4. Controller#launch 함수 호출

대부분의 경우에 Controller에 따른 로직은 launch 함수에 위치해야 합니다. Application과 Profile의 launch 함수 이후에 호출되기 때문에, 앱의 초기 UI는 이 위치에 오는 것이 적절합니다. 앱이 실행되기 전에 처리되어야 하는 로직은 init 함수 내부에 구현해야 합니다.



[Refgs and Control]

Controller의 가장 중요한 것은 Ref와 Control입니다. 이 둘은 앱 내부에서 컴포넌트에 대한 레퍼런스를 쉽게 얻어서, 컴포넌트에서 발생하는 이벤트를 처리하는데 사용됩니다.


[Refs]

Ref는 강력한 ComponentQuery 문법을 사용하여 컴포넌트를 쉽게 위치할 수 있도록 합니다. Ref는 Controller에 원하는 만큼 정의할 수 있습니다. 예를 들어, ID가 'mainNav'라는 컴포넌트를 찾는 'nav'라는 ref를 정의하였습니다. 그런 다음 addLogoutButton 내부에서 ref를 사용하였습니다.

Ext.define('MyApp.controller.Main', {

    extend: 'Ext.app.Controller',


    config: {

        refs: {

            nav: '#mainNav'

        }

    },


    addLogoutButton: function() {

        this.getNav().add({

            text: 'Logout'

        });

    }

});

보통 ref는 key/value 짝입니다 - key(이 경우에 'nav')는 생성될 레퍼런스의 이름이고, value(이 경우에는 '#mainNav')는 컴포넌트를 찾기 위한 ComponentQuery 셀렉터입니다.

addLogoutButton이라는 간단한 함수를 만들어 'getNav' 함수를 통해 ref를 사용하였습니다. 이런 getter 함수는 ref 정의에 기반하여 생성되며 항상 같은 포맷을 따릅니다 - 'get' 다음에 ref 명 첫글자를 대문자로 바꾸어 붙입니다. 이 경우 nav 레퍼런스를 툴바인 것처럼 사용하였고, 함수가 호출되었을 때 로그아웃 버튼을 추가하였습니다. 이 ref는 아래와 같이 툴바를 인식합니다.

Ext.create('Ext.Toolbar', {

    id: 'mainNav',


    items: [

        {

            text: 'Some Button'

        }

    ]

});

이 툴바는 addLogoutButton 함수 호출 시 생성되었다고 가정하고(어떻게 호출되는지는 나중에 봅시다), 두번째 버튼을 가지게 됩니다.


[Advanced Refs]

Ref는 name과  selector이외에 추가적인 옵션을 가질 수 있습니다. autoCreate와 xtype이 있으며 거의 항상 사용됩니다.

Ext.define('MyApp.controller.Main', {

    extend: 'Ext.app.Controller',


    config: {

        refs: {

            nav: '#mainNav',


            infoPanel: {

                selector: 'tabpanel panel[name=fish] infopanel',

                xtype: 'infopanel',

                autoCreate: true

            }

        }

    }

});

Controller에 두번째 ref를 추가하였습니다. 동일하게 이름은 key이고(이경우에 'infoPanel'), 하지만 이번에는 value로 객체를 전달하였습니다. 이번에는 약간 복잡한 selector 쿼리를 사용하였습니다 - 앱이 탭패널을 포함하고 있고, 아이템 중 하나가 'fish'라는 이름을 가지고 있다고 가정합시다. selector는 xtype이 탭패널 내부에서 'infopanel'인 컴포넌트를 찾습니다.

여기에서 차이는 Controller에서 this.getInfoPanel을 호출 했을 때, 'fish' 패널 내부에 infopanel이 존재하지 않는 경우 자동으로 생성된다는 점입니다.


[Control]

Control은 컴포넌트에서 발생하는 이벤트를 감지하고 Controller가 반응할 수 있도록 하는 수단입니다. Control은 ComponentQuery selector나 ref를 key로, 리스너 객체를 value로 가질 수 있습니다.

Ext.define('MyApp.controller.Main', {

    extend: 'Ext.app.Controller',


    config: {

        control: {

            loginButton: {

                tap: 'doLogin'

            },

            'button[action=logout]': {

                tap: 'doLogout'

            }

        },


        refs: {

            loginButton: 'button[action=login]'

        }

    },


    doLogin: function() {

        // called whenever the Login button is tapped

    },


    doLogout: function() {

        // called whenever any Button with action=logout is tapped

    }

});

여기서 두개의 control을 선언하였습니다 - 하나는 loginButton ref, 다른 하나는 'logout' 액션을 받는 모든 버튼을 위한 것입니다. 각각의 선언에 대해서 우리는 하나의 이벤트 핸들러를 주었습니다 - 버튼이 탭 이벤트를 발생하는 경우 실행되어야 하는 액션을 명시하였습니다. Control 블럭 내부에서 'doLogin'과 'doLogout' 함수 이름을 문자열로 정의한 것을 주목합시다.

각 control에서 원하는 대로 이벤트를 감지할 수 있고, key로써 ComponentQuery selector와 ref를 mix and match 시킬 수 있습니다.



[Routes]

Sencha Touch 2에서는 Controller가 어떤 route를 따를 것인지 직접적으로 명시할 수 있습니다. 이는 앱에서 히스토리 지원을 가능하게 하며, route를 제공하는 임의의 부분으로 링크가 가능하게 합니다.

예를 들어, 로그인과 사용자 프로필을 보여주기 위한 Controller가 있고, url을 통해서 화면에 접근가능하게 하고자 합니다. 아래와 같이 시도해 볼 수 있습니다.

Ext.define('MyApp.controller.Users', {

    extend: 'Ext.app.Controller',


    config: {

        routes: {

            'login': 'showLogin',

            'user/:id': 'showUserById'

        },


        refs: {

            main: '#mainTabPanel'

        }

    },


    // uses our 'main' ref above to add a loginpanel to our main TabPanel (note that

    // 'loginpanel' is a custom xtype created for this application)

    showLogin: function() {

        this.getMain().add({

            xtype: 'loginpanel'

        });

    },


    // Loads the User then adds a 'userprofile' view to the main TabPanel

    showUserById: function(id) {

        MyApp.model.User.load(id, {

            scope: this,

            success: function(user) {

                this.getMain().add({

                    xtype: 'userprofile',

                    user: user

                });

            }

        });

    }

});

위에서 명시한 route는 브라우저 어드레스 바의 콘텐츠를 Controller의 함수에 매핑하여 route가 매치된 경우 호출 될 수 있도록 합니다. http://myapp.com/#login에 매치되는 route를 login route 처럼 간단한 텍스트로 사용할 수 있고, http://myapp.com/#user/123과 같은 url에 매치되는 route를 위해서 와일드카드를 포함하여 'user/:id'와 같이 사용할 수도 있습니다. 어드레스가 바뀌면 Controller는 자동적으로 명시된 함수를 호출합니다.

showUserById 함수 내부에서 User 객체를 최초로 로드해야 함을 알아둡시다. route를 사용할 때, route가 호출하는 함수는 데이터를 로딩하고 상태를 복원하는데 전적인 책임을 가지고 있습니다. 로드되어 캐시된 데이터를 클리어하기 때문에, 사용자는 url을 다른 사람에게 보내주거나 페이지를 간단히 리프레시 할 수 있습니다. route를 통한 상태 복원에 대해서는 application architecture guides에 더 자세히 설명되어 있습니다.



[Before Filters]

Routing 컨텍스트에서 Controller가 제공하는 마지막 기능은 route에 정의된 함수 이전에 실행되는 filter 함수를 정의하는 것입니다. 이는 사용자 인증하기 위한 동작, 또는 페이지에 필요한 클래스 로딩을 위한 최적의 위치입니다. 예를 들어, 사용자가 쇼핑몰의 Product를 수정하기 위해서는 인증이 필요하다고 합시다.

Ext.define('MyApp.controller.Products', {

    config: {

        before: {

            editProduct: 'authenticate'

        },


        routes: {

            'product/edit/:id': 'editProduct'

        }

    },


    // this is not directly because our before filter is called first

    editProduct: function() {

        //... performs the product editing logic

    },


    // this is run before editProduct

    authenticate: function(action) {

        MyApp.authenticate({

            success: function() {

                action.resume();

            },

            failure: function() {

                Ext.Msg.alert('Not Logged In', "You can't do that, you're not logged in");

            }

        });

    }

});

사용자가 http://myapp.com/#product/edit/123과 같은 url을 네비게이트할 때, Controller의 authenticate 함수가 호출되어 Ext.app.Action을 전달합니다. Action은 before filter가 존재하지 않는 경우 실행될 것입니다. Action은 Controller와 function(이 경우에는 editProduct) 그리고 url에서 파싱된 ID 등의 데이터를 표현합니다.

filter는 동기, 비동기에 관계없이 필요한 어떤 동작이든 실행할 수 있습니다. 이경우에 사용자가 현재 로그인 되어있는지 확인하기 위해서  authenticate 함수를 사용합니다. 서버에서 사용자의 인증을 확인하여 비동기로 동작하기 위한 AJAX 요청을 할 수 있습니다 - 인증이 성공한 경우 action.resume() 함수를 호출 함으로써 action을 계속하고, 실패할 경우 먼저 로그인 하도록 알릴 수 있습니다.

어떤 action이 실행되기 전에 before filter는 추가적인 클래스를 로드하는데 사용할 수 있습니다. 예를 들어, 잘 사용되지 않는 어떤 action이 있을 때 소스코드 로딩을 지연시켜 Application의 구동이 빨라지게 할 수 있습니다. 이를 위해서 Ext.Loader를 사용하는 filter를 설정함으로써 필요시 코드를 로드할 수 있습니다.

action마다 여러개의 before filter를 명시할 수 있고, 그럴 때는 배열로 전달할 수 있습니다.

Ext.define('MyApp.controller.Products', {

    config: {

        before: {

            editProduct: ['authenticate', 'ensureLoaded']

        },


        routes: {

            'product/edit/:id': 'editProduct'

        }

    },


    // this is not directly because our before filter is called first

    editProduct: function() {

        //... performs the product editing logic

    },


    // this is the first filter that is called

    authenticate: function(action) {

        MyApp.authenticate({

            success: function() {

                action.resume();

            },

            failure: function() {

                Ext.Msg.alert('Not Logged In', "You can't do that, you're not logged in");

            }

        });

    },


    // this is the second filter that is called

    ensureLoaded: function(action) {

        Ext.require(['MyApp.custom.Class', 'MyApp.another.Class'], function() {

            action.resume();

        });

    }

});

filter는 순서대로 실행되고, 각각은 action.resume() 호출해야 합니다.



[Profile-specific Controllers]

수퍼클래스:

Ext.define('MyApp.controller.Users', {

    extend: 'Ext.app.Controller',


    config: {

        routes: {

            'login': 'showLogin'

        },


        refs: {

            loginPanel: {

                selector: 'loginpanel',

                xtype: 'loginpanel',

                autoCreate: true

            }

        },


        control: {

            'logoutbutton': {

                tap: 'logout'

            }

        }

    },


    logout: function() {

        // code to close the user's session

    }

});

폰 Controller:

Ext.define('MyApp.controller.phone.Users', {

    extend: 'MypApp.controller.Users',


    config: {

        refs: {

            nav: '#mainNav'

        }

    },


    showLogin: function() {

        this.getNav().setActiveItem(this.getLoginPanel());

    }

});

태블릿 Controller:

Ext.define('MyApp.controller.tablet.Users', {

    extend: 'MyApp.controller.Users',


    showLogin: function() {

        this.getLoginPanel().show();

    }

});


Posted by ssun++

댓글을 달아 주세요

[참고문서]

http://docs.sencha.com/touch/2-0/#!/guide/apps_intro



[Intro to Applications with Sencha Touch 2]

Sencha Touch 2는 멀티 플랫폼에서의 앱 빌드에 최적화 되어 있습니다. 가능한 간단하게 앱을 작성하기 위해서, MVC 패턴에 기반한 간단하면서도 강력한 앱 아키텍쳐를 제공합니다. 이러한 접근법은 코드가 깔끔하고, 테스트가 가능하며, 유지보수가 용이하도록 합니다. 그리고 앱 작성시에 여러가지 장점을 제공합니다.

  • History Support : 앱 내부와 앱이 링크될 수 있는 어느 부분에서 백 버튼 완벽 지원
  • Deep Linking : 앱에서 화면을 여는 deep link에 대한 공유.
  • Device Profiles : 폰, 태블릿 등 커먼 코드를 공유하는 그밖의 디바이스에 대해서 쉬운 UI 커스터마이징



[Anatomy of an Application]

앱은 Model, View, Controller, Store, Profile, 그밖에 추가적인 메타데이터(아이콘, 실행 이미지등을 명시한)의 집합입니다.

  • Model : 앱에서 오브젝트의 타입을 나타냅니다. 예를 들어, 전자 상거래 앱에서 사용자, 상품, 주문에 대한 모델을 가질 수 있습니다.
  • Views : 사용자에게 데이터를 보여주며 Sencha Touch의 빌트인 컴포넌트를 사용합니다.
  • Controllers : 사용자의 탭, 스와이프 등의 동작을 핸들링하며 앱과의 인터랙션을 처리합니다.
  • Stores : 데이터를 앱으로 로딩하여 List나 DataView와 같은 컴포넌트를 사용할 수 있도록 합니다.
  • Profiles : 코드를 가능한 많이 공유하면서 디바이스에 따라 UI를 커스터마이징 할 수 있도록 합니다.

Sencha Touch 앱에서 일반적으로 Application이 가장 먼저 정의되며, 아래와 같은 형태입니다.

Ext.application({

    name: 'MyApp',

    models: ['User', 'Product', 'nested.Order'],

    views: ['OrderList', 'OrderDetail', 'Main'],

    controllers: ['Orders'],


    launch: function() {

        Ext.create('MyApp.view.Main');

    }

});

name은 모든 Model, View, Controller 등 모든 클래스를 포함한 전체 앱을 위한 글로벌 네임스페이스를 생성하는데 사용됩니다. 예를 들어, MyApp 앱은 MyApp.model.User, MyApp.controller.Users, MyApp.view.Main과 같은 패턴을 따르는 구성 클래스를 사용해야 합니다. 이러한 방식은 앱이 페이지의 다른 코드와 충돌 할 확률을 최소화 합니다.

앱은 클래스들을 앱으로 로드하기 위해 Model, View, Controller 설정을 사용합니다. 이를 위해 파일 구조에 대한 간단한 규칙을 따릅니다 - Model은 app/model 디렉토리에 위치하며, Contorller는 app/controller 디렉토리, View는 app/view 디렉토리에 위치합니다 - 예를 들어, app/model/User.js, app/controllers/Orders.js, app/view/Main.js와 같은 형태가 됩니다.

우리가 정의한 Model 중 하나다 나머지와 다른 것을 볼 수 있습니다 - 풀 클래스 명으로 ("MyApp.model.nested.Order")를 명시하였습니다. 설정에 대하여 네이밍 규칙을 다르지 않을 경우, 풀 클래스 명을 명시할 수 있습니다. 커스텀 의존성을 명시하는 방법에 대해서는  Dependencies section of the Ext.app.Application docs에 자세히 나와 있습니다.



[Controllers]

Controller는 앱을 하나로 합치는 역할을 합니다. UI에 의한 이벤트 발생시 그에 대한 액션을 취합니다. 이는 코드의 가독성을 높여주며, View 로직과 Control 로직을 분리시킵니다.

예를 들어, 로그인 폼을 통해서 사용자가 로그인하는 과정이 필요하다고 합시다. 이 경우의 View는 모든 필드와 콘트롤을 포함한 폼이 됩니다. Controller는 submit 버튼의 tap 이벤트를 감지해서 인증을 수행해야 합니다. 데이터나 상태를 조작하는 경우, View가 아닌 Controller가 그러한 변화를 활성화해야 합니다.

Controller는 작지만 강력한 기능들을 가지고 있으며 간단한 규칙을 따릅니다. 각 Controller는 Ext.app.Controller의 서브클래스 입니다 (이미 존재하는 Controller의 서브클래스를 만들 수도 있지만, 동시에 Ext.app.Controller를 상속해야 합니다).

모든 Controller가 Ext.app.Controller의 서브클래스라고 하더라도, 각 Contorller는 Application이 로드될 때 한번만 객체화 됩니다. Controller는 하나의 객체만이 있으며, Controller 객체 집합은 Application이 내부적으로 관리합니다. Application의 Controller 설정을 사용하여 모든 Controller를 로드하고 객체화 합니다.



[Stores]

Store는 Sencha Touch의 중요한 부분이며 대부분의 data-bind 위젯을 동작시킵니다. 간단하게 말하면, Store는 Model 객체의 배열입니다. List나 DataView와같은 data-bind 컴포넌트는 Store의 Model 객체를 렌더합니다. Store 이벤트가 발생하여 Store에서 Model 객체가 추가되거나 삭제되면, data-bind 컴포넌트는 이벤트를 감지하여 업데이트 합니다.

Store가 무엇이며 앱에서 컴포넌트와 연동되는지에 대한 정보는 Store guide에 더 많은 정보가 있지만, 반드시 알아야 할 Application 객체와의 통합 지점들이 있습니다.



[Device Profiles]

Sencha Touch는 다양한 디바이스의 화면 사이즈에 맞게 동작합니다. 태블릿에서 잘 동작하는 UI가 폰에서 동작하지 않을 수도 있고, 반대의 경우가 될 수도 있기 때문에 다양한 디바이스 타입에 따라 커스터마이징 된 뷰를 제공하는 것이 좋습니다. 하지만 다른 UI를 제공하기 위해 앱을 여러번 작성하는 것을 원하지 않습니다 - 가능한 코드를 공유하는 것이 좋습니다.

Device Profile은 앱에서 지원할 디바이스 타입과 어떻게 처리되어야 하는지를 정의할 수 있는 간단한 클래스 입니다. 처음에는 Device Profile 없이 개발했다가 나중에 추가할 수도 있고, 전혀 사용하지 않아도 됩니다. Profile은 현재 디바이스에서 사용될 경우 true를 리턴하는 isActive 함수와 추가적으로 로드할 Model, View, Controller를 정의합니다.

앱의 Profile을 사용하기 위해서는 Application에 Profile에 대해 알려주어야 하며 Ext.app.Profile의 서브클래스를 생성해야 합니다.

Ext.application({

    name: 'MyApp',

    profiles: ['Phone', 'Tablet'],


    //as before

});

위와 같이 Profile을 정의함으로써 Application은 app/profile/Phone.js와 app/profile/Tablet.js를 로드하게 됩니다. 태블릿 버전은 추가적인 기능을 가진다고 합시다. 아래는 태블릿 Profile을 정의에 대한 예제입니다.

Ext.define('MyApp.profile.Tablet', {

    extend: 'Ext.app.Profile',


    config: {

        controllers: ['Groups'],

        views: ['GroupAdmin'],

        models: ['MyApp.model.Group']

    },


    isActive: function() {

        return Ext.os.is.Tablet;

    }

});

isActive 함수는 앱이 실행되는 동안 Sencha Touch가 태블릿에서 실행되고 있는지를 리턴합니다. 디바이스의 모양과 사이즈가 다양해지면서 폰과 태블릿의 경계가 불명확해지고 있기때문에 이것은 약간 주관적인 판정이 될 수 있습니다. 디바이스가 태블릿인지 폰인지 판단할 간단한 방법이 없기때문에, iPad에서 실행될 때는 Sencha Touch의 Ext.os.is.Tablet이 true로 set되고 나머지 경우에는 false로 set됩니다. 섬세한 제어가 필요한 경우 isActive 함수 내부에 구현을 추가하는 것이 가장 쉬운 방법입니다.

단 하나의 Profile만이 isActive 함수에서 true를 리턴하는 것을 보장해야 합니다. 하나 이상의 Profile이 true를 리턴하는 경우, 최초의 Profile이 적용되며 나머지는 무시됩니다. 최초의 Profile은 앱의 currentProfile로 set되며, 어느때나 사용될 수 있습니다.

currentProfile이 추가적인 Model, View, Controller, Store를 정의한 경우 앱은 자동으로 로드 합니다. 하지만, 풀 클래스 명이 제공되지 않는 경우 Profile에 정의된 의존성은 Profile 명과 결합됩니다. 예를 들어,

  • views: ['GroupAdmin']app/view/tablet/GroupAdmin.js를 로드합니다.
  • controllers: ['Groups']app/controller/tablet/Groups.js를 로드합니다.
  • models: ['MyApp.model.Group']app/model/Group.js를 로드합니다.

대부분의 경우에 Profile은 추가적인 Controller, View, Model, Store를 정의하기만 합니다. Profile에 대한 상세한 논의는 device profiles guide에서 볼 수 있습니다.



[Launch Process]

Application은 launch 함수를 정의할 수 있다. launch 함수는 앱의 모든 클래스가 로드되어 실행될 준비가 되었을 때 호출됩니다. 일반적으로 메인 뷰 생성 등 앱의 스타트업 로직을 집어넣기에 최적의 위치입니다.

launch 함수 이외에도 스타트업 로직을 추가할 수 있는 두개의 위치가 있습니다. 먼저 각 Controller는 init 함수를 정의할 수 있습니다. init 함수는 launch 함수가 실행되기 전에 호출됩니다. 다음으로 Device Profile을 사용하는 경우, Profile은 launch 함수를 정의할 수 있습니다. Profile의 launch 함수는 Controller의 init 함수가 실행되고 Application의 launch 함수가 실행되기 전에 호출됩니다.

활성화 된 Profile만이 launch 함수가 호출됨을 알아둡시다. 예를 들어, 폰과 태블릿에 대한 Profile을 정의하여 태블릿 앱이 실행되었다면, 태블릿 Profile의 launch 함수만이 호출됩니다.

  1. Controller#init 함수 호출
  2. Profile#launch 함수 호출
  3. Application#launch 함수 호출
  4. Controller#launch 함수 호출

각 Profile은 스타트업 시 생성되어야하는 View 집합을 가지기 때문에, Profile을 사용할 때는 부트업 로직을 Profile의 launch 함수에 위치하는 것이 일반적입니다. 



[Routing and History Support]

Sencha Touch 2는 Routing과 History에 대해서 완벽히 지원합니다. SDK 예제에서 History 지원을 사용하여, 백버튼을 사용하고 스크린간 전환을 편리하게 합니다 - 안드로이드에서 유용합니다.

History 지원에 대한 문서가 있습니다. Kitchen Sink 예제에서 배울 수 있으며, Routing과 상태 복원에 대한 문서는 History 지원을 필요로 합니다.

Posted by ssun++

댓글을 달아 주세요

[참고 문서]

http://docs.sencha.com/touch/2-0/#!/guide/first_app



[준비하기]

이 가이드는 Getting Started Guide에 기반하고 있습니다. 그 가이드는 SDK를 설치하고 기능적으로 완벽한 환경을 구축할 수 있도록 합니다. 아직 읽지 않았을 경우 먼저 읽은 후 돌아오기를 추천합니다.



[우리가 빌드 할 것]

간단한 당신 회사의 모바일 사이트로 사용할 수도 있는 웹사이트 형태의 앱을 빌드할 것입니다. 홈페이지, contact form, 최근 블로그 포스트를 가져와서 읽을 수 있는 목록을 추가해 볼 것입니다.



[시작하기]

먼저 해야 할 것은 Getting Started Guide에서 한 것과 같이, 앱을 설치하는 것입니다. 앱은 탭패널을 사용하여 4개의 페이지를 포함하게 될 것이며 아래와 같이 시작할 것입니다.

Ext.application({

    name: 'Sencha',


    launch: function() {

        Ext.create("Ext.tab.Panel", {

            fullscreen: true,

            items: [

                {

                    title: 'Home',

                    iconCls: 'home',

                    html: 'Welcome'

                }

            ]

        });

    }

});

브라우저에서 앱을 실행해보면 화면에 탭 패널이 나타나야 합니다. 홈페이지에 콘텐츠를 추가하고, 페이지 하단의 탭 바를 재배치해 봅시다. tabBarPosition 설정을 통해서 이를 구현하고 HTML을 조금 추가해 봅시다.

Ext.application({

    name: 'Sencha',


    launch: function() {

        Ext.create("Ext.tab.Panel", {

            fullscreen: true,

            tabBarPosition: 'bottom',


            items: [

                {

                    title: 'Home',

                    iconCls: 'home',

                    html: [

                        '<img src="http://staging.sencha.com/img/sencha.png" />',

                        '<h1>Welcome to Sencha Touch</h1>',

                        "<p>You're creating the Getting Started app. This demonstrates how ",

                        "to use tabs, lists and forms to create a simple app</p>",

                        '<h2>Sencha Touch (2.0.0)</h2>'

                    ].join("")

                }

            ]

        });

    }

});

HTML을 볼 수 있어야하지만, 그다지 아름답게 보이지는 않을 것입니다. cls 설정을 panel에 추가함으로써, CSS 클래스를 추가하고 좀 더 아름답게 보이도록 할 수 있습니다. 우리가 추가하는 모든 CSS는 examples/getting_started/index.html에 있습니다.



[블로그 페이지 추가하기]

피드를 가져오기 위해서 구글의 Feed API Service를 사용할 것입니다. 관련된 코드가 많기 때문에 우선은 결과를 확인하고, 어떻게 동작하는지 확인합시다.

이번에는 sencha.com/blog에서 최신 블로그 포스트를 가져오기 위해, panel 대신 nestedlist를 사용합니다. Nested List를 사용하여, 리스트를 탭핑함으로써 블로그 엔트리로 드릴 다운 할 수 있습니다.

Ext.application({

    name: 'Sencha',


    launch: function() {

        Ext.create("Ext.tab.Panel", {

            fullscreen: true,

            tabBarPosition: 'bottom',


            items: [

                {

                    xtype: 'nestedlist',

                    title: 'Blog',

                    iconCls: 'star',

                    displayField: 'title',


                    store: {

                        type: 'tree',


                        fields: [

                            'title', 'link', 'author', 'contentSnippet', 'content',

                            {name: 'leaf', defaultValue: true}

                        ],


                        root: {

                            leaf: false

                        },


                        proxy: {

                            type: 'jsonp',

                            url: 'https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&q=http://feeds.feedburner.com/SenchaBlog',

                            reader: {

                                type: 'json',

                                rootProperty: 'responseData.feed.entries'

                            }

                        }

                    }

                }

            ]

        });

    }

});

Nested List에 간단한 설정과 - title, iconCls, displatField - 그리고 상세한 store 라는 설정을 주었습니다. Store 설정은 Nested List에 어떻게 데이터를 가져오는지 알려줍니다. store 설정을 차례로 살펴봅시다.

  • type : tree는 Nested List가 사용하는 tree store를 생성합니다.
  • fields는 Store에 블로그 데이터로 어떤 필드를 예상하는지 알려줍니다.
  • proxy는 Store에 데이터를 어디서 가져올지 알려줍니다. 잠시 후에 더 자세히 살펴 볼 것입니다.
  • root는 leaf가 아닌 root 노드를 알려줍니다. 위에서 leaf의 defaultValue는 true로 설정하였고 root에 대해서 오버라이드 할 필요가 있습니다.

모든 Store 설정에서 proxy가 가장 중요한 역할을 합니다. 우리는 블로그 데이터를 JSON-P 형식으로 얻기 위해 proxy에 구글 Feed API Service를 사용할 것을 알려줍니다. 이것은 우리가 임의의 블로그에서 데이터를 쉽게 가져와서 보여줄 수 있도록 합니다. (예를 들어, Sencha 블로그 url을 http://rss.slashdot.org/Slashdot/slashdot으로 바꾸어 Slashdot의 feed를 가져올 수 있습니다.)

proxy 정의의 마지막 부분은 Reader였습니다. Reader는 구글로부터의 데이터를 사용가능한 데이터로 디코드해주는 역할을 합니다. 구글이 블로그 데이터를 보내줄 때, 아래와 같이 JSON 오브젝트로 감싸서 보내줍니다.

{

    responseData: {

        feed: {

            entries: [

                {author: 'Bob', title: 'Great Post', content: 'Really good content...'}

            ]

        }

    }

}

우리가 관심 있는 것은 엔트리의 배열이기 때문에, Reader의 rootProperty를 'responseData.feed.entries'로 설정하였고 프레임워크가 나머지를 하도록 합니다.



[파보기]

Nested List가 데이터를 가져와서 보이도록 하였으므로, 마지막으로 해주어야 할 것은 사용자가 엔트리를 선택하여 읽을 수 있도록 하는 것입니다. 두 가지 설정을 추가하여 이것을 완료하도록 할 것입니다.

{

    xtype: 'nestedlist',

    //all other configurations as above


    detailCard: {

        xtype: 'panel',

        scrollable: true,

        styleHtmlContent: true

    },


    listeners: {

        itemtap: function(nestedList, list, index, element, post) {

            this.getDetailCard().setHtml(post.get('content'));

        }

    }

}

여기서 Nested List의 중요한 기능인 detailCard를 설정하였습니다. 이는 사용자가 아이템을 탭했을 때 다른 뷰를 보이도록 할 수 있습니다. 우리는 detailCard가 스크롤 가능한 Panel이 되도록 설정하였습니다. styleHtmlContent를 사용하여 텍스트가 보기 좋게 하였습니다.

퍼즐의 마지막 조각은 itemtap 리스너를 추가하는 것입니다. 아이템이 탭 되었을 때 함수를 호출합니다. 우리의 함수는 detailCard의 HTML을 사용자가 탭한 포스트의 콘텐츠로 설정하는 것이 전부이고, detail card에 포스트가 보이도록 하는 과정의 애니메이션은 프레임워크가 담당합니다. 이것은 블로그 리더가 동작하도록 하기 위한 코드입니다.



[Contact Form 만들기]

마지막으로 할 것은 contact form을 만드는 것입니다. 사용자의 이름, 이메일 주소, 메세지를 가지도록 할 것이고 FieldSet을 사용하여 아름답게 보이도록 할 것입니다. 아래 코드는 간단한 샘플입니다.

Ext.application({

    name: 'Sencha',


    launch: function() {

        Ext.create("Ext.tab.Panel", {

            fullscreen: true,

            tabBarPosition: 'bottom',


            items: [

                {

                    title: 'Contact',

                    iconCls: 'user',

                    xtype: 'formpanel',

                    url: 'contact.php',

                    layout: 'vbox',


                    items: [

                        {

                            xtype: 'fieldset',

                            title: 'Contact Us',

                            instructions: '(email address is optional)',

                            items: [

                                {

                                    xtype: 'textfield',

                                    label: 'Name'

                                },

                                {

                                    xtype: 'emailfield',

                                    label: 'Email'

                                },

                                {

                                    xtype: 'textareafield',

                                    label: 'Message'

                                }

                            ]

                        },

                        {

                            xtype: 'button',

                            text: 'Send',

                            ui: 'confirm',

                            handler: function() {

                                this.up('formpanel').submit();

                            }

                        }

                    ]

                }

            ]

        });

    }

});

이번에 fieldset을 포함하는 form을 만들어보았습니다. fieldset은 3개의 field를 포함합니다 - 각각 이름, 이메일, 메시지. VBox 레이아웃을 사용하여 아이템을 세로로 정렬합니다.

하단에 탭 핸들러를 가지는 Button을 추가하였습니다. 유용한 up 함수를 사용하였으며, 이는 버튼이 있는 form panel을 반환합니다. 그리고 submit 함수를 호출하여 상단에 명시한 url(contact.php)로 폼을 제출합니다.



[합체하기]

각각의 뷰를 개별적으로 만들어보았고, 완성된 앱으로 합쳐봅시다.

풀 소스는 Sencha Touch 2.0 SDK의 getting started 앱의 examples/getting_started 폴더에 있습니다.

Posted by ssun++

댓글을 달아 주세요

[참고 문서]

http://docs.sencha.com/touch/2-0/#!/guide/getting_started



[이전 포스트]

[Sencha Touch 2] 시작하기 1



[코드 살펴보기]

이전 포스트에서 생성한 GS 디렉토리를 살펴봅시다. 디렉토리 구조는 아래와 같습니다.

각 파일/디렉토리에 대한 설명입니다.

  • app - Model, View, Controller, Store를 포함하는 디렉토리
  • app.js - JavaScript 엔트리 포인트
  • app.json - Builder가 최소화 버전을 만들 때 사용하는 config 파일
  • index.html - 앱의 HTML 파일
  • packager.json - Packager가 native 앱(iOS나 Android)을 만들 때 사용하는 config 파일
  • resources - CSS, Image를 포함하는 디렉토리
  • sdk - Sencha Touch SDK 사본

엔트리 포인트인 app.js를 살펴봅시다.

Ext.application({

    name: 'GS', // 앱의 네임스페이스. 모든 클래스는 GS로 시작함. ex) GS.view.Main


    requires: [

        'Ext.MessageBox' // 필요한 클래스. 이 앱은 MessageBox를 사용함.

    ],


    views: ['Main'], // 앱의 뷰 리스트


    icon: { // 앱을 iOS 장치의 홈 스크린에 추가할 때 사용할 아이콘 리스트.

        '57': 'resources/icons/Icon.png',

        '72': 'resources/icons/Icon~ipad.png',

        '114': 'resources/icons/Icon@2x.png',

        '144': 'resources/icons/Icon~ipad@2x.png'

    },


    isIconPrecomposed: true,


    startupImage: {

        '320x460': 'resources/startup/320x460.jpg',

        '640x920': 'resources/startup/640x920.png',

        '768x1004': 'resources/startup/768x1004.png',

        '748x1024': 'resources/startup/748x1024.png',

        '1536x2008': 'resources/startup/1536x2008.png',

        '1496x2048': 'resources/startup/1496x2048.png'

    },


    launch: function() { // 앱이 초기화 될 때 호출되는 함수. 로딩 인디케이터를 숨기고 'Main' 뷰를 뷰포트에 추가함

        // Destroy the #appLoadingIndicator element

        Ext.fly('appLoadingIndicator').destroy();


        // Initialize the main view

        Ext.Viewport.add(Ext.create('GS.view.Main'));

    },


    onUpdated: function() { // 개발 환경에 앱이 설치되고, 앱의 업데이트가 있을 때 호출되는 함수

        Ext.Msg.confirm(

            "Application Update",

            "This application has just successfully been updated to the latest version. Reload now?",

            function(buttonId) {

                if (buttonId === 'yes') {

                    window.location.reload();

                }

            }

        );

    }

});

앱의 엔트리 포인트는 launch 함수입니다. 디폴트 앱에서는 먼저 로딩 인디케이터를 숨긴 후, 메인 뷰를 생성하여 뷰포트에 추가합니다.

뷰포트는 카드 레이아웃이며 컴포넌트를 추가할 수 있습니다. 디폴트 앱은 Main 뷰를 뷰포트에 추가하여 화면에 보이게 됩니다. Main 뷰의 코드를 살펴봅시다.

app/view/Main.js 파일을 열어서 12 라인을 수정해봅시다.

title : 'Home Tab'

21 라인을 수정해봅시다.

title: 'Woohoo!'

24-28 라인을 수정해봅시다.

html: [

    "I changed the default <b>HTML Content</b> to something different!"

].join("")

앱을 리프레시 해서 변경사항을 확인해봅시다.


Posted by ssun++

댓글을 달아 주세요

[참고 문서]

http://docs.sencha.com/touch/2-0/#!/guide/getting_started



[필요한 것들]

Sencha Touch 2 SDKSDK Tools를 다운 받습니다.

그리고 웹서버, 웹브라우저(크롬, 사파리 권장)가 필요합니다.



[설치하기]

SDK zip 파일을 프로젝트 디렉토리에 압축 해제합니다. 이 폴터는 http 서버를 통해서 접근 가능해야 합니다. 예를 들어, 웹브라우저를 통해 접근하여 Sencha Touch 문서를 볼 수 있어야 합니다.


SDK Tools 인스톨러를 실행합니다. SDK Tools는 sencha 커맨드 경로를 path에 추가하여 application template를 생성할 수 있게 됩니다.



[App 생성하기]

Sencha Touch SDK 경로에서 아래와 같이 입력합니다.

$ sencha generate app GS ../GS

이는 변수 GS로 namespace(?)된 스켈레톤을 생성하여 ../GS 디렉토리에 위치하도록 합니다. 스켈레톤은 app을 만들기 위한 모든 파일을 포함하고 있습니다 (index.html, Touch SDK 사본, CSS. 이미지, 네이티브 패키징 설정 등)


App이 생성되었는지 웹브라우저를 통해 확인할 수 있습니다. (예를 들어 http://localhost/GS)


Posted by ssun++

댓글을 달아 주세요

최근에 달린 댓글

최근에 받은 트랙백

글 보관함