Help us understand the problem. What is going on with this article?

ViewControllerを肥大化させないためのMVC構成案

More than 1 year has passed since last update.

前置き

ここには、iPhoneアプリ開発の入門書を読み終えた私がいざアプリ開発を始めたものの ViewControllerが肥大化し悲惨な有様になってしまったため、各種Webサイトなどから情報収集した上で、アプリ開発が破綻しないための MVC+VM(dataSource)プロジェクト構成を記載しています。

アプリ開発エンジニアとしては新米ですので、至らぬ点は多々ございますがご容赦願います。

全体のプロジェクト構成

題材として取り上げるサンプルプロジェクトは、ECアプリの商品一覧画面のようなものを出力するプロジェクトです。ホーム画面は、スワイプによるページング(カテゴリ切り替え)と、コレクションビューによる商品リスト表示機能を持ちます。

プロジェクト構成抜粋
.
├── Commons
│   ├── Config.h
│   ├── Config.m
│   ├── Utility.h
│   └── Utility.m
├── Controllers
│   ├── BaseViewController.h
│   ├── BaseViewController.m
│   └── Home
│       ├── HomeItemsCollectionViewController.h
│       ├── HomeItemsCollectionViewController.m
│       ├── HomeItemsPageViewController.h
│       ├── HomeItemsPageViewController.m
│       ├── HomeViewController.h
│       └── HomeViewController.m
├── Models
│   ├── CategoriesModel.h
│   ├── CategoriesModel.m
│   ├── ItemsModel.h
│   └── ItemsModel.m
├── Resources
├── ViewModels
│   └── Home
│       ├── HomeItemsCollectionViewDataSource.h
│       ├── HomeItemsCollectionViewDataSource.m
│       ├── HomeItemsPageViewControllerDataSource.h
│       └── HomeItemsPageViewControllerDataSource.m
├── Views
│   └── Home
│       ├── HomeItemsCollectionView.h
│       ├── HomeItemsCollectionView.m
│       ├── HomeItemsCollectionViewCell.h
│       ├── HomeItemsCollectionViewCell.m
│       ├── HomeView.h
│       └── HomeView.m
├── en.lproj
│   └── InfoPlist.strings
└── main.m

プロジェクト構成の解説

  1. Commonsには、MVCを問わずどこでも使える汎用的なクラスを置きます。ConfigやUtility系のクラスはここに配置します。

  2. Controllersには、主にViewControllerを置きます。
    直下には置かずHome画面(機能)であればHomeグループを作ります。
    その下に、HomeViewController及びHomeViewControllerのChildViewControllerをまとめて配置します。
    Home画面の開発の時以外はHomeグループを閉じる事で、ファイル数が増えてもディレクトリツリーが見づらくならないように対処します。

  3. Viewsの下にはViewを配置します。ほぼControllersと同じです。画面単位でグループを切りわけて、HomeView及び紐付くSubViewをまとめて配置します。ここではSubViewの追加や配置などのViewロジックを担当させます。

  4. ViewModelsの下には主にDataSourceを配置します。
    これは、調査の過程でMVVMの構成を調査した際に、DataSourceが何となくViewModelっぽかったので勝手に名付けているだけです。MVVMにおけるViewModelの役割とDataSourceは違うかもしれませんが、目的はあくまで ViewControllerからDataSourceを切り離す事です。

  5. Modelsの下には、データ関連のクラスを置きます。DBだけでなく、 Apiからのデータ取得などの処理もデータ単位でクラス化して、ViewControllerから切り離します。

  6. Resourcesの下には画像などを置きます。

(グループの切り分けは画面単位と書きましたが、グループ単位で非表示にすることで開発をし易くすることと、単位ごとに分ける事で構成を見やすくする意味しか持ちません。切り分けの単位は任意かと思います)

プロジェクト構成の解説(ざっくり)

Models (データ処理)
→ ファイルアクセス、DBアクセス、Api通信など
→ データ単位(商品やカテゴリ)で区切ってファイルを作る

Views (表示処理)
→ UI作成、配置、サブビューの追加など
→ UIViewおよびそのサブクラス

Controllers (操作処理)
→ 画面遷移、UIが操作された時の処理、View・Model・ViewModelの橋渡しを行う仲介役など
→ UIViewController及びサブクラス

ViewModels (表示用データ処理)
→ Viewで表示するためのデータの生成
→ ◯◯DataSource(UICollectionViewDataSource)系の役割を果たすクラス

Commons(共通)
→ MVCを問わない汎用系クラス

Resources
→ 画像など

ViewControllerの分割

1つのViewControllerに全てを書くとソースコードが肥大化し易いため、ViewControllerは分割させます。

商品一覧ページで言えば、スワイプで違うカテゴリの商品リストページを表示するための ItemsPageViewController、各カテゴリの商品一覧を表示するための ItemsCollectionViewControllerです。

これらは全てHome画面を出力するためのコントローラですが、ページングからコレクションまで全ての処理をHomeViewControllerに記述してしまうと複雑な構成になってしまうため、ある程度機能としてまとまっていて独立可能な処理は、 ChildViewControllerとして派生させた方が分かり易くなるかと思います。

(本来のChileViewControllerの用途とは違うような気もしていますが)

HomeViewController.m
#import "HomeViewController.h"
#import "HomeItemsPageViewController.h"
#import "HomeView.h"
#import "Utility.h"

@interface HomeViewController ()

@property (nonatomic) HomeItemsPageViewController *itemsPageViewController;
@property (nonatomic) HomeView *homeView;

@end

@implementation HomeViewController

- (void)loadView
{
    [super loadView];

    self.homeView = [[HomeView alloc] initWithFrame:[[UIScreen mainScreen] applicationFrame]];
    self.view = self.homeView;
}

- (void)viewDidLoad
{
    [super viewDidLoad];

    // 商品リストのページビュー
    // 全てHomeItemsPageViewControllerに任せることで分割
    self.itemsPageViewController = [[HomeItemsPageViewController alloc] init];
    [self addChildViewController:self.itemsPageViewController];
    [self.homeView.contentsView addSubview:self.itemsPageViewController.view];
}    

ちょっと待った、そのself.dataSource = self

我々は、 self.dataSource でDataSourceの選択権を与えられています。安易に開発していると、self.dataSource = selfとしてしまいがちです。
勿論、ViewControllerが健康体であれば何も気にする必要はありません。しかし、肥満児であれば分割した方が懸命でしょう。

HomeItemsPageViewController.m
    self.dataSource = self.itemsPageViewControllerDataSource;
HomeItemsPageViewControllerDataSource.m
#import "HomeItemsPageViewControllerDataSource.h"
#import "HomeItemsCollectionViewController.h"

@implementation HomeItemsPageViewControllerDataSource

#pragma mark - UIPageViewControllerDataSource

- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerBeforeViewController:(UIViewController *)viewController
{
    HomeItemsCollectionViewController *homeItemsCollectionViewController = (HomeItemsCollectionViewController *)viewController;

    // ~~~中略~~~

    return [[HomeItemsCollectionViewController alloc] initWithCategoryId:categoryId];
}

- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerAfterViewController:(UIViewController *)viewController
{
    HomeItemsCollectionViewController *homeItemsCollectionViewController = (HomeItemsCollectionViewController *)viewController;

    // ~~~中略~~~

    return [[HomeItemsCollectionViewController alloc] initWithCategoryId:categoryId];
}

@end

この程度のソースコード分量であれば、ViewControllerにおいても問題は発生しないかもしれません。しかし、データソースが肥大であればあるほど、効果を発揮する事でしょう。

Modelに移植したApiからのデータ取得をKvoで監視する

Apiからのデータ取得をModelに移動させるということは、勿論Apiを叩くのはModelになります。アプリ開発では非同期アクセスが主流ですので、例えば下記のようなソースコードになるかと思います。

ItemsModel.h
#import <Foundation/Foundation.h>

@interface ItemsModel : NSObject

@property (nonatomic) NSArray *items;
@property (nonatomic) NSError *error;

- (void)getItemsWithCategoryId:(NSInteger)categoryId;

@end
ItemsModel.m
#import "ItemsModel.h"
#import "Config.h"
#import "Utility.h"
#import <AFNetworking.h>

@implementation ItemsModel

- (void)getItemsWithCategoryId:(NSInteger)categoryId
{
    AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];
    manager.responseSerializer = [AFCompoundResponseSerializer
                                  compoundSerializerWithResponseSerializers:@[[AFJSONResponseSerializer serializer], [AFHTTPResponseSerializer serializer]]];

    [manager GET:ConfigItemsApiUrl
      parameters:@{@"categoryId":[Utility stringValue:categoryId]}
         success:^(AFHTTPRequestOperation *operation, NSDictionary *responseObject) {
             self.items = (NSArray *)responseObject[@"items"];
             self.error = nil;
         }
         failure:^(AFHTTPRequestOperation *operation, NSError *error) {
             self.items = nil;
             self.error = error;
             DebugLog(@"%@", error);
         }];
}

@end

おすすめなのは、ModelのpropertyをKvoで監視する方法です。

HomeItemsCollectionViewController.m
    // 初回表示商品
    ItemsModel *itemsModel = [[ItemsModel alloc] init];
    [itemsModel addObserver:self forKeyPath:@"items" options:NSKeyValueObservingOptionNew context:@"init"];
HomeItemsCollectionViewController.m
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
   // 商品の追加種別
    NSString *contextMessgae = (__bridge NSString *)context;
    ItemsModel *itemsModel = (ItemsModel *)object;

    if ([contextMessgae isEqualToString:@"init"]) {
        // 初回表示商品
        self.collectionViewDataSource.items = [itemsModel.items mutableCopy];
        [self.collectionView reloadData];

        // 初回ローディングを停止
        [self.collectionView.collectionInitActivityIndicatorView stopAnimating];

        // KVOを止める
        [itemsModel removeObserver:self forKeyPath:@"items"];
    }

    // ~~~ 中略~~~
}

現実的にはitemsだけでなくerrorも監視しなければならないなど考慮するべきことも多いですが、Modelにデータ処理を移植し、Kvoで監視して表示の更新処理だけをViewControllerに記述する方法は、ダイエットに役に立つのではないかと思っています。

Viewについて

ViewはinitWithFrame内で、背景色の指定や自View内のSubViewの追加など、各種初期化処理を実施します。
addSubView及び見た目を定義するだけの処理はViewControllerに置かず、出来る限りView側に置きます。勿論、目的はViewControllerのダイエットです。

layoutSubviewsではboundsから領域の幅と高さを取得して、各種SubViewを配置していきます。画面全体ではなく、あくまで担当するView内での座標となりますので注意が必要です。

HomeItemCollectionViewCell.m
#import "HomeItemsCollectionViewCell.h"
#import "UIImageView+WebCache.h"

@implementation HomeItemsCollectionViewCell

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        self.backgroundColor = [UIColor clearColor];

        // 商品画像
        self.itemImageView = [[UIImageView alloc] init];
        self.itemImageView.backgroundColor = [UIColor clearColor];
        self.itemImageView.contentMode = UIViewContentModeScaleAspectFit;
        [self addSubview:self.itemImageView];

        // 商品名
        self.itemNameLabel = [[UILabel alloc] init];
        self.itemNameLabel.textColor = [UIColor whiteColor];
        self.itemNameLabel.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.2];
        self.itemNameLabel.adjustsFontSizeToFitWidth = YES;
        //self.itemNameLabel.numberOfLines = 5;
        [self addSubview:self.itemNameLabel];

         // ~~~ 中略 ~~~

        // 商品価格
        self.itemPriceLabel = [[UILabel alloc] init];
        self.itemPriceLabel.adjustsFontSizeToFitWidth = YES;
        [self addSubview:self.itemPriceLabel];
    }

    return self;
}

 - (void)layoutSubviews
{
    [super layoutSubviews];

    CGFloat width = self.bounds.size.width;
    CGFloat height = self.bounds.size.height;

    self.itemImageView.frame = CGRectMake(0, 0, width, height * 0.8);
    self.itemNameLabel.frame = CGRectMake(0, height * 0.6, width, height * 0.2);
    // ~~~ 中略 ~~~
    self.itemPriceLabel.frame = CGRectMake(width * 0.6, height * 0.8, width * 0.4, height * 0.2);
}

@end

今回は全てソースコードでUIを実装しました。StoryboardやXibを用いる場合での開発では、もっと違う構成があるかもしません。

Delegateは分割できるのか?

我々は、DataSourceと同じくDelegateに関しても self.delegateで分割する選択権を与えられています。しかし、Delegateの分割はDataSourceと比べて難易度が高いと言わざるを得ません。
dataSourceが表示用データの置き場所という単純な役割なのに対して、delegateは何らかの操作(タップやスクロールなど)によって起こる動作を記述するため、 Controller的側面が強くなってしまうため分割しづらいためです。
そのため、現状delegateの処理はコントローラに置いています。しかし、上手に分割する方法があれば取り入れてみたいものです。

ViewControllerを肥大化させないためのまとめ

  1. ViewControllerが複雑になる場合は、PageやCollectionなどの機能単位で派生して分割させる
  2. 通信によるデータ取得などのデータロジックはModels側に置く (Kvoがおすすめ)
  3. self.dataSource = selfの多用はやめよう
  4. addSubiView及びや座標指定や背景色の指定など見た目を定義する処理はViewsに置く
  5. ViewControllerに多くの事を求めない
sasaron397
プログラミングは雑食です。最近は、面白いと思ったものを色々と試しています。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away